package handlers import ( "fmt" "net/http" "path/filepath" "strings" "time" "github.com/flatrender/file-svc/internal/db" "github.com/flatrender/file-svc/internal/middleware" "github.com/flatrender/file-svc/internal/models" "github.com/flatrender/file-svc/internal/storage" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) type FileHandler struct { store *db.Store minio *storage.MinioClient bucket string // default upload bucket name } func NewFileHandler(store *db.Store, minio *storage.MinioClient, bucket string) *FileHandler { return &FileHandler{store: store, minio: minio, bucket: bucket} } // GET /v1/files func (h *FileHandler) ListFiles(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) var req models.FileListRequest if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}}) return } if req.Page < 1 { req.Page = 1 } if req.PageSize < 1 || req.PageSize > 100 { req.PageSize = 20 } files, total, err := h.store.ListFiles(c.Request.Context(), userID, req) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } if files == nil { files = []models.UserFile{} } c.JSON(http.StatusOK, models.PagedResponse[models.UserFile]{ Items: files, Meta: models.PaginationMeta{Page: req.Page, PageSize: req.PageSize, Total: total, TotalPages: db.TotalPages(total, req.PageSize)}, }) } // GET /v1/files/:id func (h *FileHandler) GetFile(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}}) return } file, err := h.store.GetFile(c.Request.Context(), id, userID) if err == pgx.ErrNoRows { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}}) return } if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.JSON(http.StatusOK, file) } // POST /v1/files/presigned-upload func (h *FileHandler) PresignedUpload(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID) var req models.PresignedUploadRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}}) return } if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } ext := strings.ToLower(filepath.Ext(req.Filename)) key := fmt.Sprintf("uploads/%s/%s%s", userID, uuid.New(), ext) uploadURL, err := h.minio.PresignedPutURL(c.Request.Context(), h.bucket, key, 15*time.Minute) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}}) return } fileKind := guessFileKind(ext, req.MimeType) file := &models.UserFile{ TenantID: tenantID, UserID: userID, UserFolderID: req.TargetFolderID, Name: req.Filename, OriginalFilename: &req.Filename, FileExtension: &ext, MimeType: req.MimeType, FileType: fileKind, MinioBucket: h.bucket, MinioKey: key, FileAddress: fmt.Sprintf("minio://%s/%s", h.bucket, key), SizeBytes: req.SizeBytes, UploadStatus: models.UploadStatusPending, Source: strPtr("upload"), } if err := h.store.CreateFileRecord(c.Request.Context(), file); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.JSON(http.StatusOK, models.PresignedUploadResponse{ UploadURL: uploadURL, FileID: file.ID, ExpiresAt: time.Now().Add(15 * time.Minute), }) } // POST /v1/files/:id/confirm func (h *FileHandler) ConfirmUpload(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}}) return } file, err := h.store.GetFile(c.Request.Context(), id, userID) if err == pgx.ErrNoRows { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}}) return } if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } if err := h.store.MarkFileReady(c.Request.Context(), id, nil); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } if err := h.store.AddUsedBytes(c.Request.Context(), userID, file.SizeBytes); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.JSON(http.StatusOK, gin.H{"status": "ready"}) } // DELETE /v1/files/:id func (h *FileHandler) DeleteFile(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}}) return } file, err := h.store.DeleteFile(c.Request.Context(), id, userID) if err == pgx.ErrNoRows { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}}) return } if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } _ = h.minio.DeleteObject(c.Request.Context(), file.MinioBucket, file.MinioKey) _ = h.store.AddUsedBytes(c.Request.Context(), userID, -file.SizeBytes) c.Status(http.StatusNoContent) } // GET /v1/files/:id/download func (h *FileHandler) GetDownloadURL(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}}) return } file, err := h.store.GetFile(c.Request.Context(), id, userID) if err == pgx.ErrNoRows { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "file not found"}}) return } if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } dlURL, err := h.minio.PresignedGetURL(c.Request.Context(), file.MinioBucket, file.MinioKey, time.Hour) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "storage_error", Message: err.Error()}}) return } c.JSON(http.StatusOK, gin.H{"url": dlURL, "expires_in": 3600}) } // GET /v1/quota func (h *FileHandler) GetQuota(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID) if err := h.store.EnsureQuota(c.Request.Context(), userID, tenantID); err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } quota, err := h.store.GetQuota(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.JSON(http.StatusOK, quota) } // ── Folders ─────────────────────────────────────────────────────────────────── // GET /v1/folders func (h *FileHandler) ListFolders(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) var parentID *uuid.UUID if p := c.Query("parent_id"); p != "" { id, err := uuid.Parse(p) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid parent_id"}}) return } parentID = &id } folders, err := h.store.GetFolders(c.Request.Context(), userID, parentID) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } if folders == nil { folders = []models.UserFolder{} } c.JSON(http.StatusOK, folders) } // POST /v1/folders func (h *FileHandler) CreateFolder(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) tenantID := c.MustGet(middleware.KeyTenantID).(uuid.UUID) var req models.CreateFolderRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: err.Error()}}) return } folder, err := h.store.CreateFolder(c.Request.Context(), tenantID, userID, req) if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.JSON(http.StatusCreated, folder) } // DELETE /v1/folders/:id func (h *FileHandler) DeleteFolder(c *gin.Context) { userID := c.MustGet(middleware.KeyUserID).(uuid.UUID) id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, models.ErrorResponse{Error: models.APIError{Code: "bad_request", Message: "invalid id"}}) return } if err := h.store.DeleteFolder(c.Request.Context(), id, userID); err == pgx.ErrNoRows { c.JSON(http.StatusNotFound, models.ErrorResponse{Error: models.APIError{Code: "not_found", Message: "folder not found"}}) return } else if err != nil { c.JSON(http.StatusInternalServerError, models.ErrorResponse{Error: models.APIError{Code: "db_error", Message: err.Error()}}) return } c.Status(http.StatusNoContent) } // ── Helpers ─────────────────────────────────────────────────────────────────── func guessFileKind(ext string, mime *string) models.FileKind { if mime != nil { switch { case strings.HasPrefix(*mime, "video/"): return models.FileKindVideo case strings.HasPrefix(*mime, "image/"): return models.FileKindImage case strings.HasPrefix(*mime, "audio/"): return models.FileKindAudio } } switch ext { case ".mp4", ".mov", ".avi", ".webm", ".mkv": return models.FileKindVideo case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg": return models.FileKindImage case ".mp3", ".wav", ".ogg", ".aac", ".flac": return models.FileKindAudio } return models.FileKindOther } func strPtr(s string) *string { return &s }