Files
tm_back/pkg/filestore/handler.go
T

504 lines
14 KiB
Go

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