5fbfcd4149
continuous-integration/drone/push Build is passing
- Updated the `UploadDocuments` method to sanitize document file IDs before saving, ensuring only valid references are stored. - Introduced `DetachDocumentFileID` method in the `companyService` to remove file IDs from all companies referencing a deleted file, improving data integrity. - Enhanced the `companyRepository` with a new method to handle the removal of document file IDs from the database. - Updated the `filestore` handler to utilize the new detachment functionality when files are deleted, ensuring consistent state across domain entities. This update improves the management of document file IDs within the company domain, enhancing data integrity and reference handling.
521 lines
15 KiB
Go
521 lines
15 KiB
Go
package filestore
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"tm/pkg/logger"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
// DocumentReferenceCleaner removes stored file IDs from domain entities when a file is deleted.
|
|
type DocumentReferenceCleaner interface {
|
|
DetachDocumentFileID(ctx context.Context, fileID string) error
|
|
}
|
|
|
|
// Handler handles HTTP requests for file operations
|
|
type Handler struct {
|
|
service FileStoreService
|
|
logger logger.Logger
|
|
referenceCleaner DocumentReferenceCleaner
|
|
}
|
|
|
|
// NewHandler creates a new file handler
|
|
func NewHandler(service FileStoreService, logger logger.Logger, referenceCleaner DocumentReferenceCleaner) *Handler {
|
|
return &Handler{
|
|
service: service,
|
|
logger: logger,
|
|
referenceCleaner: referenceCleaner,
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
})
|
|
}
|
|
|
|
if h.referenceCleaner != nil {
|
|
if cleanErr := h.referenceCleaner.DetachDocumentFileID(c.Request().Context(), fileID); cleanErr != nil {
|
|
h.logger.Warn("Failed to detach deleted file from domain references", map[string]interface{}{
|
|
"file_id": fileID,
|
|
"error": cleanErr.Error(),
|
|
})
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|