466 lines
9.9 KiB
Go
466 lines
9.9 KiB
Go
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
|
|
}
|