185 lines
5.5 KiB
Go
185 lines
5.5 KiB
Go
package system
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"kra/internal/utils/uploadpolicy"
|
|
)
|
|
|
|
type MediaRepo interface {
|
|
MediaMetadataRepo
|
|
UploadRepo
|
|
}
|
|
|
|
type StoredFile struct {
|
|
Name string
|
|
Path string
|
|
URL string
|
|
Size int64
|
|
LastModified time.Time
|
|
ContentType string
|
|
}
|
|
|
|
// FileStorage owns the persistence boundary for uploaded files.
|
|
type FileStorage interface {
|
|
Put(context.Context, string, io.Reader) (*StoredFile, error)
|
|
Open(context.Context, string) (io.ReadCloser, error)
|
|
Delete(context.Context, string) error
|
|
Compose(context.Context, []string, string) (*StoredFile, string, error)
|
|
DeletePrefix(context.Context, string) error
|
|
List(context.Context, string, string, int) ([]*StoredFile, string, bool, error)
|
|
}
|
|
|
|
type MediaUsecase struct {
|
|
MediaRepo
|
|
files FileStorage
|
|
settings RuntimeSettings
|
|
}
|
|
|
|
var mediaDeleteMu sync.Mutex
|
|
|
|
var ErrMediaTooLarge = errors.New("文件超过大小上限")
|
|
|
|
func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase {
|
|
return &MediaUsecase{MediaRepo: repo, files: files, settings: settings}
|
|
}
|
|
|
|
func (uc *MediaUsecase) maxMediaFileSize() int64 {
|
|
if uc.settings == nil {
|
|
return MediaSettings{}.EffectiveMaxFileSize()
|
|
}
|
|
return uc.settings.MediaSettings().EffectiveMaxFileSize()
|
|
}
|
|
|
|
func validateMediaName(name string) error {
|
|
return uploadpolicy.ValidateFileName(name)
|
|
}
|
|
func (uc *MediaUsecase) Upload(ctx context.Context, userID uint, name, suppliedMIME string, categoryID int, reader io.Reader, save bool) (*MediaFile, error) {
|
|
if err := validateMediaName(name); err != nil {
|
|
return nil, err
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
buffered := bufio.NewReader(reader)
|
|
// Keep the multipart Content-Type when the client supplied one.
|
|
// Only infer it when the header is empty: first by extension, then by
|
|
// sniffing the first 512 bytes. Overwriting a valid client MIME with
|
|
// http.DetectContentType changes the media record for common uploads
|
|
// (for example SVG/JSON), and diverges from the compatible DetectMIME helper.
|
|
if strings.TrimSpace(suppliedMIME) == "" {
|
|
if detected := uploadpolicy.MIMEByExtension(ext); detected != "" {
|
|
suppliedMIME = detected
|
|
} else if header, _ := buffered.Peek(512); len(header) > 0 {
|
|
suppliedMIME = http.DetectContentType(header)
|
|
} else {
|
|
suppliedMIME = "application/octet-stream"
|
|
}
|
|
}
|
|
key := time.Now().Format("20060102") + "/" + uuid.NewString() + ext
|
|
hash := md5.New()
|
|
maxSize := uc.maxMediaFileSize()
|
|
limited := &io.LimitedReader{R: buffered, N: maxSize + 1}
|
|
stored, err := uc.files.Put(ctx, key, io.TeeReader(limited, hash))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
readSize := maxSize + 1 - limited.N
|
|
if stored == nil {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, errors.New("文件存储未返回结果")
|
|
}
|
|
if readSize > maxSize || stored.Size > maxSize {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, ErrMediaTooLarge
|
|
}
|
|
if stored.Size != readSize {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, errors.New("文件存储大小不一致")
|
|
}
|
|
media := &MediaFile{Name: name, CategoryID: categoryID, URL: stored.URL, Tag: strings.TrimPrefix(filepath.Ext(name), "."), Key: key, Size: stored.Size, Mime: suppliedMIME, MD5: hex.EncodeToString(hash.Sum(nil)), UserID: userID}
|
|
if save {
|
|
count, countErr := uc.MediaKeyReferences(ctx, key)
|
|
if countErr != nil {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, countErr
|
|
}
|
|
if count == 0 {
|
|
if err = uc.CreateMedia(ctx, media); err != nil {
|
|
_ = uc.files.Delete(ctx, key)
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
return media, nil
|
|
}
|
|
|
|
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
|
|
mediaDeleteMu.Lock()
|
|
defer mediaDeleteMu.Unlock()
|
|
media, err := uc.FindMedia(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
count, err := uc.MediaKeyReferences(ctx, media.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count <= 1 {
|
|
if err = uc.files.Delete(ctx, media.Key); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return uc.DeleteMedia(ctx, id)
|
|
}
|
|
func (uc *MediaUsecase) ListStorage(ctx context.Context, prefix, cursor string, limit int) ([]*StoredFile, string, bool, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
// Multipart chunks are an internal staging detail. The compatible local
|
|
// implementation keeps them outside the configured object-storage root, so
|
|
// the public listing must not expose them even when the selected backend is
|
|
// also used for staging.
|
|
chunkRoot := "uploads/chunks"
|
|
if uc.settings != nil {
|
|
if configured := strings.Trim(uc.settings.MediaSettings().ChunkDir, "/\\ "); configured != "" {
|
|
chunkRoot = configured
|
|
}
|
|
}
|
|
chunkRoot = strings.Trim(chunkRoot, "/") + "/"
|
|
result := make([]*StoredFile, 0, limit)
|
|
nextCursor := cursor
|
|
for {
|
|
// Ask only for the remaining visible capacity. Otherwise a page that is
|
|
// partially filtered could fill the response halfway through the next
|
|
// storage page while returning that page's end cursor, skipping objects.
|
|
remaining := limit - len(result)
|
|
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, remaining)
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
for _, item := range items {
|
|
if item == nil || strings.HasPrefix(strings.TrimPrefix(item.Path, "/"), chunkRoot) {
|
|
continue
|
|
}
|
|
result = append(result, item)
|
|
if len(result) == limit {
|
|
return result, next, more, nil
|
|
}
|
|
}
|
|
if !more || next == "" || next == nextCursor {
|
|
return result, next, more, nil
|
|
}
|
|
nextCursor = next
|
|
}
|
|
}
|