package filestore import ( "errors" "net/http" "strconv" "time" "tm/pkg/logger" "github.com/labstack/echo/v4" ) // Handler handles HTTP requests for file operations type Handler struct { service FileStoreService logger logger.Logger } // NewHandler creates a new file handler func NewHandler(service FileStoreService, logger logger.Logger) *Handler { return &Handler{ service: service, logger: logger, } } // UploadResponse represents the response for a successful upload type UploadResponse struct { FileID string `json:"file_id"` Filename string `json:"filename"` ContentType string `json:"content_type"` Size int64 `json:"size"` Message string `json:"message"` } // BatchUploadResponse represents the response for a multi-file upload type BatchUploadResponse struct { Uploaded []UploadResponse `json:"uploaded"` Failed []FailedUploadResult `json:"failed,omitempty"` Message string `json:"message"` } // FileInfoResponse represents file information in API response type FileInfoResponse struct { FileID string `json:"file_id"` Filename string `json:"filename"` ContentType string `json:"content_type"` Size int64 `json:"size"` UploadedBy string `json:"uploaded_by,omitempty"` UploadedAt time.Time `json:"uploaded_at"` Category string `json:"category,omitempty"` Tags []string `json:"tags,omitempty"` Description string `json:"description,omitempty"` Extra map[string]string `json:"extra,omitempty"` } // ListFilesResponse represents the response for list files operation type ListFilesResponse struct { Files []FileInfoResponse `json:"files"` Total int64 `json:"total"` Limit int64 `json:"limit"` Skip int64 `json:"skip"` } // ErrorResponse represents an error response type ErrorResponse struct { Error string `json:"error"` Code string `json:"code,omitempty"` Message string `json:"message,omitempty"` } // Upload handles file upload // @Summary Upload a file // @Description Upload a file to GridFS storage // @Tags Files // @Accept multipart/form-data // @Produce json // @Param file formData file true "File to upload" // @Param category formData string false "File category" // @Param tags formData []string false "File tags" // @Param description formData string false "File description" // @Success 200 {object} UploadResponse // @Failure 400 {object} ErrorResponse // @Failure 413 {object} ErrorResponse "File too large" // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/upload [post] func (h *Handler) Upload(c echo.Context) error { file, err := c.FormFile("file") if err != nil { h.logger.Warn("No file provided in upload request", map[string]interface{}{ "error": err.Error(), }) return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "No file provided", Code: ErrCodeInvalidInput, Message: "Please provide a file to upload", }) } uploadedBy := h.getUploaderID(c) if uploadedBy == "" { return c.JSON(http.StatusUnauthorized, ErrorResponse{ Error: "Unauthorized", Code: ErrCodeAccessDenied, Message: "Authenticated user not found in request context", }) } opts := h.buildUploadOptions(c, uploadedBy) result, err := UploadFromMultipartFile(h.service, file, opts) if err != nil { return h.writeUploadError(c, file.Filename, err) } return c.JSON(http.StatusOK, UploadResponse{ FileID: result.FileID, Filename: result.Filename, ContentType: result.ContentType, Size: result.Size, Message: "File uploaded successfully", }) } // UploadBatch handles multi-file upload in a single request // @Summary Upload multiple files // @Description Upload up to 20 files to GridFS storage in one request. Use form field "files" (repeat for each file) or a single "file". // @Tags Files // @Accept multipart/form-data // @Produce json // @Param files formData file true "Files to upload" // @Param category formData string false "File category" // @Param tags formData []string false "File tags" // @Param description formData string false "File description" // @Success 200 {object} BatchUploadResponse // @Failure 400 {object} ErrorResponse // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/upload/batch [post] func (h *Handler) UploadBatch(c echo.Context) error { uploadedBy := h.getUploaderID(c) if uploadedBy == "" { return c.JSON(http.StatusUnauthorized, ErrorResponse{ Error: "Unauthorized", Code: ErrCodeAccessDenied, Message: "Authenticated user not found in request context", }) } form, err := c.MultipartForm() if err != nil { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Invalid multipart form", Code: ErrCodeInvalidInput, Message: err.Error(), }) } files := CollectMultipartFiles(form) if len(files) == 0 { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "No files provided", Code: ErrCodeInvalidInput, Message: "Use form field \"files\" with one or more files", }) } if len(files) > MaxBatchUploadFiles { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Too many files", Code: ErrCodeInvalidInput, Message: "Maximum 20 files per request", }) } opts := h.buildUploadOptions(c, uploadedBy) results, failed := UploadManyFromMultipart(h.service, files, opts) if len(results) == 0 { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "All uploads failed", Code: ErrCodeUploadFailed, Message: "No files were uploaded successfully", }) } uploaded := make([]UploadResponse, len(results)) for i, r := range results { uploaded[i] = UploadResponse{ FileID: r.FileID, Filename: r.Filename, ContentType: r.ContentType, Size: r.Size, Message: "File uploaded successfully", } } message := "Files uploaded successfully" if len(failed) > 0 { message = "Some files uploaded successfully" } return c.JSON(http.StatusOK, BatchUploadResponse{ Uploaded: uploaded, Failed: failed, Message: message, }) } func (h *Handler) buildUploadOptions(c echo.Context, uploadedBy string) UploadOptions { return UploadOptions{ UploadedBy: uploadedBy, Category: c.FormValue("category"), Tags: c.Request().Form["tags"], Description: c.FormValue("description"), MaxSize: MaxUploadSize, } } func (h *Handler) writeUploadError(c echo.Context, filename string, err error) error { if IsFileTooLarge(err) { h.logger.Warn("File upload failed: file too large", map[string]interface{}{ "filename": filename, }) return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{ Error: "File is too large", Code: ErrCodeFileTooLarge, Message: "Maximum file size is 100 MB", }) } if IsUnsupportedType(err) { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Unsupported file type", Code: ErrCodeUnsupported, Message: err.Error(), }) } var fsErr *FileStoreError if errors.As(err, &fsErr) && fsErr.Code == ErrCodeInvalidInput { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Invalid file", Code: ErrCodeInvalidInput, Message: err.Error(), }) } h.logger.Error("File upload failed", map[string]interface{}{ "filename": filename, "error": err.Error(), }) return c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: "Upload failed", Code: ErrCodeUploadFailed, Message: "Failed to upload file to storage", }) } // Download handles file download // @Summary Download a file // @Description Download a file by its ID // @Tags Files // @Produce application/octet-stream // @Param file_id path string true "File ID" // @Success 200 {file} bytes // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/{file_id}/download [get] func (h *Handler) Download(c echo.Context) error { fileID := c.Param("file_id") if fileID == "" { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "File ID is required", Code: ErrCodeInvalidInput, Message: "Please provide a valid file ID", }) } // Download file reader, fileInfo, err := h.service.Download(fileID) if err != nil { if IsNotFound(err) { h.logger.Warn("File not found", map[string]interface{}{ "file_id": fileID, }) return c.JSON(http.StatusNotFound, ErrorResponse{ Error: "File not found", Code: ErrCodeNotFound, Message: "The requested file does not exist", }) } h.logger.Error("File download failed", map[string]interface{}{ "file_id": fileID, "error": err.Error(), }) return c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: "Download failed", Code: ErrCodeDownloadFailed, Message: "Failed to download file", }) } defer reader.Close() // Set response headers c.Response().Header().Set("Content-Type", fileInfo.ContentType) c.Response().Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size, 10)) c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileInfo.Filename+`"`) c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") // Write file to response return c.Stream(http.StatusOK, fileInfo.ContentType, reader) } func (h *Handler) getUploaderID(c echo.Context) string { if userID, ok := c.Get("user_id").(string); ok && userID != "" { return userID } if customerID, ok := c.Get("customer_id").(string); ok && customerID != "" { return customerID } return "" } // GetInfo retrieves file information // @Summary Get file information // @Description Get metadata for a file by its ID // @Tags Files // @Produce json // @Param file_id path string true "File ID" // @Success 200 {object} FileInfoResponse // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/{file_id}/info [get] func (h *Handler) GetInfo(c echo.Context) error { fileID := c.Param("file_id") if fileID == "" { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "File ID is required", Code: ErrCodeInvalidInput, Message: "Please provide a valid file ID", }) } // Get file info fileInfo, err := h.service.GetFileInfo(fileID) if err != nil { if IsNotFound(err) { return c.JSON(http.StatusNotFound, ErrorResponse{ Error: "File not found", Code: ErrCodeNotFound, Message: "The requested file does not exist", }) } h.logger.Error("Failed to get file info", map[string]interface{}{ "file_id": fileID, "error": err.Error(), }) return c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: "Failed to get file info", Code: ErrCodeDownloadFailed, Message: "Unable to retrieve file information", }) } return c.JSON(http.StatusOK, FileInfoResponse{ FileID: fileInfo.FileID, Filename: fileInfo.Filename, ContentType: fileInfo.ContentType, Size: fileInfo.Size, UploadedBy: fileInfo.UploadedBy, UploadedAt: fileInfo.UploadedAt, Category: fileInfo.Category, Tags: fileInfo.Tags, Description: fileInfo.Description, Extra: fileInfo.Extra, }) } // Delete removes a file // @Summary Delete a file // @Description Delete a file by its ID // @Tags Files // @Param file_id path string true "File ID" // @Success 200 {object} map[string]string // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /api/v1/files/{file_id} [delete] func (h *Handler) Delete(c echo.Context) error { fileID := c.Param("file_id") if fileID == "" { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "File ID is required", Code: ErrCodeInvalidInput, Message: "Please provide a valid file ID", }) } // Delete file err := h.service.Delete(fileID) if err != nil { if IsNotFound(err) { return c.JSON(http.StatusNotFound, ErrorResponse{ Error: "File not found", Code: ErrCodeNotFound, Message: "The requested file does not exist", }) } h.logger.Error("File deletion failed", map[string]interface{}{ "file_id": fileID, "error": err.Error(), }) return c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: "Deletion failed", Code: ErrCodeDeleteFailed, Message: "Failed to delete file", }) } return c.JSON(http.StatusOK, map[string]string{ "message": "File deleted successfully", }) } // ListFiles lists files by category // @Summary List files // @Description List files by category with optional filtering // @Tags Files // @Produce json // @Param category query string false "File category" // @Param tags query []string false "File tags" // @Param limit query int false "Number of files to return (default 50, max 1000)" // @Param skip query int false "Number of files to skip (default 0)" // @Success 200 {object} ListFilesResponse // @Failure 500 {object} ErrorResponse // @Router /api/v1/files [get] func (h *Handler) ListFiles(c echo.Context) error { category := c.QueryParam("category") tags := c.QueryParams()["tags"] limit := int64(50) skip := int64(0) // Parse pagination params if l := c.QueryParam("limit"); l != "" { if val, err := strconv.ParseInt(l, 10, 64); err != nil { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Invalid limit parameter", Code: ErrCodeInvalidInput, Message: "Limit must be a valid integer", }) } else { limit = val } } if s := c.QueryParam("skip"); s != "" { if val, err := strconv.ParseInt(s, 10, 64); err != nil { return c.JSON(http.StatusBadRequest, ErrorResponse{ Error: "Invalid skip parameter", Code: ErrCodeInvalidInput, Message: "Skip must be a valid integer", }) } else { skip = val } } // List files files, total, err := h.service.ListFiles(category, tags, limit, skip) if err != nil { h.logger.Error("Failed to list files", map[string]interface{}{ "error": err.Error(), }) return c.JSON(http.StatusInternalServerError, ErrorResponse{ Error: "Failed to list files", Code: ErrCodeDownloadFailed, Message: "Unable to retrieve file list", }) } // Convert to response format fileResponses := make([]FileInfoResponse, len(files)) for i, f := range files { fileResponses[i] = FileInfoResponse{ FileID: f.FileID, Filename: f.Filename, ContentType: f.ContentType, Size: f.Size, UploadedBy: f.UploadedBy, UploadedAt: f.UploadedAt, Category: f.Category, Tags: f.Tags, Description: f.Description, Extra: f.Extra, } } return c.JSON(http.StatusOK, ListFilesResponse{ Files: fileResponses, Total: total, Limit: limit, Skip: skip, }) }