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 } }