Initial commit: file_manager package with local and S3 support
This commit is contained in:
465
controller/file_manager/local.go
Normal file
465
controller/file_manager/local.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package file_manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HashMismatchError struct {
|
||||
Expected string
|
||||
Actual string
|
||||
}
|
||||
|
||||
func (e *HashMismatchError) Error() string {
|
||||
return fmt.Sprintf("SHA256 hash mismatch: expected %s, got %s", e.Expected, e.Actual)
|
||||
}
|
||||
|
||||
func NewHashMismatchError(expected, actual string) error {
|
||||
return &HashMismatchError{
|
||||
Expected: expected,
|
||||
Actual: actual,
|
||||
}
|
||||
}
|
||||
|
||||
type FileNotReadyError struct {
|
||||
Code string
|
||||
CurrentSize int64
|
||||
ExpectedSize int64
|
||||
}
|
||||
|
||||
func (e *FileNotReadyError) Error() string {
|
||||
return fmt.Sprintf("file %s not ready: current size %d, expected %d", e.Code, e.CurrentSize, e.ExpectedSize)
|
||||
}
|
||||
|
||||
func NewFileNotReadyError(code string, currentSize, expectedSize int64) error {
|
||||
return &FileNotReadyError{
|
||||
Code: code,
|
||||
CurrentSize: currentSize,
|
||||
ExpectedSize: expectedSize,
|
||||
}
|
||||
}
|
||||
|
||||
type DeleteError struct {
|
||||
Code string
|
||||
Message string
|
||||
Errors []string
|
||||
}
|
||||
|
||||
func (e *DeleteError) Error() string {
|
||||
if len(e.Errors) == 0 {
|
||||
return e.Message
|
||||
}
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Errors)
|
||||
}
|
||||
|
||||
type LocalFileManager struct {
|
||||
dir string
|
||||
fileHandles sync.Map
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
timeout time.Duration
|
||||
expire time.Duration
|
||||
verifyHash bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewLocalFileManager(opts *option) *LocalFileManager {
|
||||
defaultDir := "./uploads"
|
||||
lfm := &LocalFileManager{
|
||||
dir: defaultDir,
|
||||
timeout: time.Minute,
|
||||
expire: 24 * time.Hour,
|
||||
verifyHash: true,
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
if opts.dir != nil {
|
||||
lfm.dir = *opts.dir
|
||||
}
|
||||
if opts.timeout > 0 {
|
||||
lfm.timeout = opts.timeout
|
||||
}
|
||||
if opts.expire > 0 {
|
||||
lfm.expire = opts.expire
|
||||
}
|
||||
}
|
||||
|
||||
lfm.ctx, lfm.cancel = context.WithCancel(context.Background())
|
||||
|
||||
go lfm.startCleaner()
|
||||
|
||||
return lfm
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) manifestPath(code string) string {
|
||||
return filepath.Join(lfm.dir, "."+code+".manifest")
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) readManifest(code string) (*FileInfo, error) {
|
||||
manifestPath := lfm.manifestPath(code)
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fileInfo FileInfo
|
||||
if err := json.Unmarshal(data, &fileInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileInfo, nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) writeManifest(code string, fileInfo *FileInfo) error {
|
||||
manifestPath := lfm.manifestPath(code)
|
||||
data, err := json.Marshal(fileInfo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(manifestPath, data, 0644)
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) deleteManifest(code string) error {
|
||||
return os.Remove(lfm.manifestPath(code))
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) scanManifests() ([]string, error) {
|
||||
var codes []string
|
||||
entries, err := os.ReadDir(lfm.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if len(name) > 10 && name[:1] == "." && name[len(name)-9:] == ".manifest" {
|
||||
code := name[1 : len(name)-9]
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) startCleaner() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-lfm.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
lfm.cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) cleanup() {
|
||||
codes, err := lfm.scanManifests()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, code := range codes {
|
||||
fileInfo, err := lfm.readManifest(code)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fileInfo.Complete {
|
||||
if now.Sub(fileInfo.CreateTime) > lfm.expire {
|
||||
lfm.cleanupFile(code, fileInfo)
|
||||
}
|
||||
} else {
|
||||
if now.Sub(fileInfo.CreateTime) > lfm.timeout {
|
||||
lfm.cleanupFile(code, fileInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) cleanupFile(code string, fileInfo *FileInfo) {
|
||||
if fileHandleValue, exists := lfm.fileHandles.LoadAndDelete(code); exists {
|
||||
file := fileHandleValue.(*os.File)
|
||||
file.Close()
|
||||
}
|
||||
if fileInfo.Path != "" {
|
||||
os.Remove(fileInfo.Path)
|
||||
}
|
||||
lfm.deleteManifest(code)
|
||||
}
|
||||
|
||||
func generateRandomCode(length int) (string, error) {
|
||||
bytes := make([]byte, length/2)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) CloseManager() {
|
||||
lfm.cancel()
|
||||
lfm.fileHandles.Range(func(key, value any) bool {
|
||||
code := key.(string)
|
||||
file := value.(*os.File)
|
||||
file.Close()
|
||||
lfm.fileHandles.Delete(code)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) Create(ctx context.Context, filename string, size int64, sha256 string) (*CreateResult, error) {
|
||||
code, err := generateRandomCode(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(lfm.dir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filePath := filepath.Join(lfm.dir, code)
|
||||
|
||||
fileInfo := &FileInfo{
|
||||
Filename: filename,
|
||||
Size: size,
|
||||
SHA256: sha256,
|
||||
Path: filePath,
|
||||
CreateTime: time.Now(),
|
||||
Complete: false,
|
||||
}
|
||||
|
||||
if err := lfm.writeManifest(code, fileInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
lfm.deleteManifest(code)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lfm.fileHandles.Store(code, file)
|
||||
|
||||
return &CreateResult{
|
||||
Code: code,
|
||||
SHA256: sha256,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) Upload(ctx context.Context, code string, start int64, end int64, reader io.Reader) (int64, int64, error) {
|
||||
fileHandleValue, exists := lfm.fileHandles.Load(code)
|
||||
if !exists {
|
||||
return 0, 0, os.ErrNotExist
|
||||
}
|
||||
|
||||
file := fileHandleValue.(*os.File)
|
||||
|
||||
currentInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
currentSize := currentInfo.Size()
|
||||
|
||||
if start != currentSize {
|
||||
return currentSize, 0, nil
|
||||
}
|
||||
|
||||
written, err := io.Copy(file, reader)
|
||||
if err != nil {
|
||||
return currentSize, 0, err
|
||||
}
|
||||
|
||||
newInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return currentSize, written, err
|
||||
}
|
||||
|
||||
totalSize := newInfo.Size()
|
||||
|
||||
fileInfo, err := lfm.readManifest(code)
|
||||
if err != nil {
|
||||
return totalSize, written, os.ErrNotExist
|
||||
}
|
||||
|
||||
if end > 0 && totalSize == end && totalSize == fileInfo.Size {
|
||||
if fileInfo.SHA256 != "" {
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return totalSize, written, err
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return totalSize, written, err
|
||||
}
|
||||
|
||||
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if calculatedHash != fileInfo.SHA256 {
|
||||
return totalSize, written, NewHashMismatchError(fileInfo.SHA256, calculatedHash)
|
||||
}
|
||||
}
|
||||
|
||||
if fileInfo.SHA256 == "" {
|
||||
if _, err := file.Seek(0, 0); err != nil {
|
||||
return totalSize, written, err
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return totalSize, written, err
|
||||
}
|
||||
|
||||
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
fileInfo.SHA256 = calculatedHash
|
||||
}
|
||||
|
||||
fileInfo.Complete = true
|
||||
if err := lfm.writeManifest(code, fileInfo); err != nil {
|
||||
return totalSize, written, err
|
||||
}
|
||||
|
||||
file.Close()
|
||||
lfm.fileHandles.Delete(code)
|
||||
}
|
||||
|
||||
return totalSize, written, nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) verifyFileHash(code string, fileInfo *FileInfo) error {
|
||||
if fileInfo.SHA256 == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(fileInfo.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
calculatedHash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
if calculatedHash != fileInfo.SHA256 {
|
||||
lfm.cleanupFile(code, fileInfo)
|
||||
return NewHashMismatchError(fileInfo.SHA256, calculatedHash)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) Get(ctx context.Context, code string) ([]byte, error) {
|
||||
fileInfo, err := lfm.readManifest(code)
|
||||
if err != nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
fileHandleValue, handleExists := lfm.fileHandles.Load(code)
|
||||
if handleExists {
|
||||
file := fileHandleValue.(*os.File)
|
||||
fileInfoStat, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fileInfoStat.Size() != fileInfo.Size {
|
||||
return nil, NewFileNotReadyError(code, fileInfoStat.Size(), fileInfo.Size)
|
||||
}
|
||||
|
||||
if file, exists := lfm.fileHandles.LoadAndDelete(code); exists {
|
||||
f := file.(*os.File)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if err := lfm.verifyFileHash(code, fileInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return os.ReadFile(fileInfo.Path)
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) GetInfo(ctx context.Context, code string) (*FileInfo, error) {
|
||||
fileInfo, err := lfm.readManifest(code)
|
||||
if err != nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
fileHandleValue, handleExists := lfm.fileHandles.Load(code)
|
||||
if handleExists {
|
||||
file := fileHandleValue.(*os.File)
|
||||
fileInfoStat, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fileInfoStat.Size() != fileInfo.Size {
|
||||
return nil, NewFileNotReadyError(code, fileInfoStat.Size(), fileInfo.Size)
|
||||
}
|
||||
|
||||
if file, exists := lfm.fileHandles.LoadAndDelete(code); exists {
|
||||
f := file.(*os.File)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if err := lfm.verifyFileHash(code, fileInfo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) Delete(ctx context.Context, code string) error {
|
||||
fileInfo, err := lfm.readManifest(code)
|
||||
if err != nil {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
var errors []string
|
||||
|
||||
if fileHandleValue, handleExists := lfm.fileHandles.LoadAndDelete(code); handleExists {
|
||||
file := fileHandleValue.(*os.File)
|
||||
if err := file.Close(); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to close file handle: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Remove(fileInfo.Path); err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Sprintf("failed to delete file: %v", err))
|
||||
}
|
||||
|
||||
if err := lfm.deleteManifest(code); err != nil && !os.IsNotExist(err) {
|
||||
errors = append(errors, fmt.Sprintf("failed to delete manifest: %v", err))
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return &DeleteError{
|
||||
Code: code,
|
||||
Message: "partial file deletion completed with errors",
|
||||
Errors: errors,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (lfm *LocalFileManager) Close(code string) error {
|
||||
if fileHandleValue, exists := lfm.fileHandles.LoadAndDelete(code); exists {
|
||||
file := fileHandleValue.(*os.File)
|
||||
return file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user