kra-oa/internal/biz/media.go

167 lines
5.0 KiB
Go

package biz
import (
"bufio"
"context"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
type MediaRepo interface {
MediaMetadataRepo
UploadRepo
}
type MediaUsecase struct {
MediaRepo
files FileStorage
settings RuntimeSettings
}
func NewMediaUsecase(repo MediaRepo, files FileStorage, settings RuntimeSettings) *MediaUsecase {
return &MediaUsecase{MediaRepo: repo, files: files, settings: settings}
}
var allowedMediaExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".bmp": true, ".ico": true, ".avif": true,
".mp3": true, ".wav": true, ".ogg": true, ".m4a": true, ".flac": true, ".aac": true,
".mp4": true, ".webm": true, ".mov": true, ".avi": true, ".mkv": true,
".txt": true, ".md": true, ".csv": true, ".json": true, ".log": true, ".pdf": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, ".tgz": true, ".bin": true,
}
func validateMediaName(name string) error {
if name == "" || strings.TrimSpace(name) != name || strings.ContainsAny(name, `/\`) {
return errors.New("file extension is not allowed")
}
if !allowedMediaExtensions[strings.ToLower(filepath.Ext(name))] {
return errors.New("file extension is not allowed")
}
return nil
}
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 := mediaMIMEByExtension(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()
stored, err := uc.files.Put(ctx, key, io.TeeReader(buffered, hash))
if err != nil {
return nil, err
}
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 {
return nil, countErr
}
if count == 0 {
if err = uc.CreateMedia(ctx, media); err != nil {
return nil, err
}
}
}
return media, nil
}
// mediaMIMEByExtension mirrors the small extension fallback table. It is
// intentionally narrower than mime.TypeByExtension: all other types are sniffed.
// types from the first 512 bytes.
func mediaMIMEByExtension(ext string) string {
switch ext {
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".pdf":
return "application/pdf"
case ".zip":
return "application/zip"
case ".mp4":
return "video/mp4"
case ".mp3":
return "audio/mpeg"
case ".csv":
return "text/csv"
default:
return ""
}
}
func (uc *MediaUsecase) Delete(ctx context.Context, id uint) error {
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 staged on the local filesystem, so listOssFiles
// never exposes them. Kra's storage abstraction may stage them through
// the selected backend; hide the internal prefix to preserve the API.
chunkRoot := ".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 {
items, next, more, err := uc.files.List(ctx, prefix, nextCursor, limit)
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
}
}