Merge branch 'develop' into TM-571

This commit is contained in:
m.nazemi
2026-06-03 12:36:04 +03:30
8 changed files with 487 additions and 102 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ func main() {
auditLogger := audit.NewLogger(logger) auditLogger := audit.NewLogger(logger)
userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger) userService := user.NewService(userRepository, logger, userAuthService, userValidator, notificationSDK, redisClient, auditLogger)
categoryService := company_category.NewService(categoryRepository, logger) categoryService := company_category.NewService(categoryRepository, logger)
companyService := company.NewService(companyRepository, categoryService, logger) companyService := company.NewService(companyRepository, categoryService, fileStoreService, logger)
customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger) customerService := customer.NewService(customerRepository, logger, customerAuthService, companyService, redisClient, notificationSDK, customerValidator, auditLogger)
tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage) tenderService := tender.NewService(tenderRepository, companyService, customerService, logger, aiSummarizerClient, aiSummarizerStorage, conf.AISummarizer.DefaultLanguage)
feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger) feedbackService := feedback.NewService(feedbackRepo, tenderService, companyService, logger)
+3
View File
@@ -77,6 +77,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
companiesGP.DELETE("/:id", companyHandler.Delete) companiesGP.DELETE("/:id", companyHandler.Delete)
companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus) companiesGP.PATCH("/:id/status", companyHandler.UpdateStatus)
companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification) companiesGP.PATCH("/:id/verification", companyHandler.UpdateVerification)
companiesGP.POST("/:id/documents", companyHandler.UploadDocuments)
} }
// Admin Categories Routes // Admin Categories Routes
@@ -204,6 +205,7 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
{ {
filesGP.Use(userHandler.AuthMiddleware()) filesGP.Use(userHandler.AuthMiddleware())
filesGP.POST("/upload", fileStoreHandler.Upload) filesGP.POST("/upload", fileStoreHandler.Upload)
filesGP.POST("/upload/batch", fileStoreHandler.UploadBatch)
filesGP.GET("", fileStoreHandler.ListFiles) filesGP.GET("", fileStoreHandler.ListFiles)
filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo) filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
filesGP.GET("/:file_id/download", fileStoreHandler.Download) filesGP.GET("/:file_id/download", fileStoreHandler.Download)
@@ -333,6 +335,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
{ {
publicFilesGP.Use(customerHandler.AuthMiddleware()) publicFilesGP.Use(customerHandler.AuthMiddleware())
publicFilesGP.POST("/upload", fileStoreHandler.Upload) publicFilesGP.POST("/upload", fileStoreHandler.Upload)
publicFilesGP.POST("/upload/batch", fileStoreHandler.UploadBatch)
publicFilesGP.GET("", fileStoreHandler.ListFiles) publicFilesGP.GET("", fileStoreHandler.ListFiles)
publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo) publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download) publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download)
+127
View File
@@ -0,0 +1,127 @@
package company
import (
"context"
"errors"
"mime/multipart"
"tm/pkg/filestore"
)
const (
companyDocumentCategory = "company_document"
maxCompanyDocumentsUpload = 20
)
// UploadDocumentsResponse is returned after uploading company documents.
type UploadDocumentsResponse struct {
DocumentFileIDs []string `json:"document_file_ids"`
Uploaded []filestore.UploadedFileResult `json:"uploaded"`
Failed []filestore.FailedUploadResult `json:"failed,omitempty"`
}
// UploadDocuments uploads one or more files and appends their IDs to the company.
func (s *companyService) UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error) {
if s.fileStore == nil {
return nil, errors.New("file storage is not configured")
}
if len(files) == 0 {
return nil, errors.New("no files provided")
}
if len(files) > maxCompanyDocumentsUpload {
return nil, errors.New("too many files in request")
}
company, err := s.repository.GetByID(ctx, companyID)
if err != nil {
return nil, errors.New("company not found")
}
opts := filestore.UploadOptions{
UploadedBy: uploadedBy,
Category: companyDocumentCategory,
MaxSize: filestore.MaxUploadSize,
Extra: map[string]string{
"company_id": companyID,
},
}
uploaded := make([]filestore.UploadedFileResult, 0, len(files))
failed := make([]filestore.FailedUploadResult, 0)
newIDs := make([]string, 0, len(files))
for _, file := range files {
result, uploadErr := filestore.UploadFromMultipartFile(s.fileStore, file, opts)
if uploadErr != nil {
code := filestore.ErrCodeUploadFailed
var fsErr *filestore.FileStoreError
if errors.As(uploadErr, &fsErr) {
code = fsErr.Code
}
failed = append(failed, filestore.FailedUploadResult{
Filename: file.Filename,
Error: uploadErr.Error(),
Code: code,
})
continue
}
uploaded = append(uploaded, result)
newIDs = append(newIDs, result.FileID)
}
if len(uploaded) == 0 {
s.logger.Warn("Company document upload failed for all files", map[string]interface{}{
"company_id": companyID,
"file_count": len(files),
})
return &UploadDocumentsResponse{
DocumentFileIDs: company.DocumentFileIDs,
Uploaded: uploaded,
Failed: failed,
}, errors.New("all file uploads failed")
}
company.DocumentFileIDs = mergeDocumentFileIDs(company.DocumentFileIDs, newIDs)
if err := s.repository.Update(ctx, company); err != nil {
s.logger.Error("Failed to save company documents", map[string]interface{}{
"error": err.Error(),
"company_id": companyID,
})
return nil, errors.New("failed to update company documents")
}
s.logger.Info("Company documents uploaded", map[string]interface{}{
"company_id": companyID,
"uploaded_count": len(uploaded),
"failed_count": len(failed),
"documents_total": len(company.DocumentFileIDs),
})
return &UploadDocumentsResponse{
DocumentFileIDs: company.DocumentFileIDs,
Uploaded: uploaded,
Failed: failed,
}, nil
}
func mergeDocumentFileIDs(existing, additional []string) []string {
seen := make(map[string]struct{}, len(existing)+len(additional))
merged := make([]string, 0, len(existing)+len(additional))
appendUnique := func(ids []string) {
for _, id := range ids {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
merged = append(merged, id)
}
}
appendUnique(existing)
appendUnique(additional)
return merged
}
+8 -9
View File
@@ -22,9 +22,8 @@ type (
Address *AddressForm `json:"address,omitempty"` Address *AddressForm `json:"address,omitempty"`
// Contact information // Contact information
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"` Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"` Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"`
// GridFS document references (uploaded via /api/v1/files/upload or /admin/v1/files/upload)
DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"` DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"`
// Tags for categorization and search // Tags for categorization and search
@@ -188,13 +187,13 @@ func (c *CompanyTags) ToResponse(categories []*CategoryResponse) *CompanyTagsRes
// CompanyProfileResponse represents the company profile data sent in API responses // CompanyProfileResponse represents the company profile data sent in API responses
type CompanyProfileResponse struct { type CompanyProfileResponse struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
RegistrationNumber string `json:"registration_number"` RegistrationNumber string `json:"registration_number"`
Industry string `json:"industry"` Industry string `json:"industry"`
FoundedYear *int `json:"founded_year,omitempty"` FoundedYear *int `json:"founded_year,omitempty"`
DocumentFileIDs []string `json:"document_file_ids,omitempty"` DocumentFileIDs []string `json:"document_file_ids,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
} }
// ToResponse converts Company to CompanyResponse // ToResponse converts Company to CompanyResponse
+69
View File
@@ -1,6 +1,8 @@
package company package company
import ( import (
"mime/multipart"
"tm/internal/user" "tm/internal/user"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/response" "tm/pkg/response"
@@ -307,3 +309,70 @@ func (h *Handler) UpdateVerification(c echo.Context) error {
"message": "Company verification updated successfully", "message": "Company verification updated successfully",
}, "Company verification updated successfully") }, "Company verification updated successfully")
} }
// UploadDocuments uploads multiple documents for a company (Web Panel)
// @Summary Upload company documents
// @Description Upload one or more files and attach them to the company (max 20 files per request). Use form field "files" for multiple files.
// @Tags Admin-Companies
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "Company ID"
// @Param files formData file true "Files to upload (repeat field for multiple files)"
// @Success 200 {object} response.APIResponse{data=UploadDocumentsResponse} "Documents uploaded successfully"
// @Failure 400 {object} response.APIResponse "Bad request - No files or all uploads failed"
// @Failure 404 {object} response.APIResponse "Not found - Company not found"
// @Failure 422 {object} response.APIResponse "Validation error - Too many files"
// @Failure 500 {object} response.APIResponse "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/companies/{id}/documents [post]
func (h *Handler) UploadDocuments(c echo.Context) error {
id := c.Param("id")
userID, err := user.GetUserIDFromContext(c)
if err != nil {
return response.Unauthorized(c, "User not authenticated")
}
form, err := c.MultipartForm()
if err != nil {
return response.ValidationError(c, "Invalid multipart form", err.Error())
}
files := collectCompanyUploadFiles(form)
if len(files) == 0 {
return response.BadRequest(c, "No files provided", "Use form field \"files\" with one or more files")
}
if len(files) > maxCompanyDocumentsUpload {
return response.ValidationError(c, "Too many files", "Maximum 20 files per request")
}
result, err := h.service.UploadDocuments(c.Request().Context(), id, userID, files)
if err != nil {
switch err.Error() {
case "company not found":
return response.NotFound(c, "Company not found")
case "no files provided", "too many files in request":
return response.BadRequest(c, err.Error(), "")
case "all file uploads failed":
return response.BadRequest(c, "All file uploads failed", "")
default:
return response.InternalServerError(c, "Failed to upload company documents")
}
}
message := "Company documents uploaded successfully"
if len(result.Failed) > 0 {
message = "Some company documents uploaded successfully"
}
return response.Success(c, result, message)
}
func collectCompanyUploadFiles(form *multipart.Form) []*multipart.FileHeader {
if form == nil {
return nil
}
if files := form.File["files"]; len(files) > 0 {
return files
}
return form.File["file"]
}
+9 -1
View File
@@ -4,7 +4,10 @@ import (
"context" "context"
"errors" "errors"
"mime/multipart"
"tm/internal/company_category" "tm/internal/company_category"
"tm/pkg/filestore"
"tm/pkg/logger" "tm/pkg/logger"
"tm/pkg/response" "tm/pkg/response"
) )
@@ -28,20 +31,25 @@ type Service interface {
// Get my company // Get my company
GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error) GetMyCompany(ctx context.Context, id string) (*CompanyProfileResponse, error)
// UploadDocuments uploads files and attaches them to a company
UploadDocuments(ctx context.Context, companyID, uploadedBy string, files []*multipart.FileHeader) (*UploadDocumentsResponse, error)
} }
// companyService implements the Service interface // companyService implements the Service interface
type companyService struct { type companyService struct {
repository Repository repository Repository
categoryService company_category.Service categoryService company_category.Service
fileStore filestore.FileStoreService
logger logger.Logger logger logger.Logger
} }
// NewService creates a new company service // NewService creates a new company service
func NewService(repository Repository, categoryService company_category.Service, logger logger.Logger) Service { func NewService(repository Repository, categoryService company_category.Service, fileStore filestore.FileStoreService, logger logger.Logger) Service {
return &companyService{ return &companyService{
repository: repository, repository: repository,
categoryService: categoryService, categoryService: categoryService,
fileStore: fileStore,
logger: logger, logger: logger,
} }
} }
+142 -91
View File
@@ -1,11 +1,9 @@
package filestore package filestore
import ( import (
"mime" "errors"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
"tm/pkg/logger" "tm/pkg/logger"
@@ -36,6 +34,13 @@ type UploadResponse struct {
Message string `json:"message"` 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 // FileInfoResponse represents file information in API response
type FileInfoResponse struct { type FileInfoResponse struct {
FileID string `json:"file_id"` FileID string `json:"file_id"`
@@ -81,7 +86,6 @@ type ErrorResponse struct {
// @Failure 500 {object} ErrorResponse // @Failure 500 {object} ErrorResponse
// @Router /api/v1/files/upload [post] // @Router /api/v1/files/upload [post]
func (h *Handler) Upload(c echo.Context) error { func (h *Handler) Upload(c echo.Context) error {
// Get file from request
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
h.logger.Warn("No file provided in upload request", map[string]interface{}{ h.logger.Warn("No file provided in upload request", map[string]interface{}{
@@ -94,25 +98,6 @@ func (h *Handler) Upload(c echo.Context) error {
}) })
} }
// Open uploaded file
src, err := file.Open()
if err != nil {
h.logger.Error("Failed to open uploaded file", map[string]interface{}{
"filename": file.Filename,
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Failed to open file",
Code: ErrCodeUploadFailed,
Message: "Unable to read the uploaded file",
})
}
defer src.Close()
// Get form data
category := c.FormValue("category")
tags := c.Request().Form["tags"]
description := c.FormValue("description")
uploadedBy := h.getUploaderID(c) uploadedBy := h.getUploaderID(c)
if uploadedBy == "" { if uploadedBy == "" {
return c.JSON(http.StatusUnauthorized, ErrorResponse{ return c.JSON(http.StatusUnauthorized, ErrorResponse{
@@ -122,34 +107,135 @@ func (h *Handler) Upload(c echo.Context) error {
}) })
} }
// Detect content type if not provided opts := h.buildUploadOptions(c, uploadedBy)
contentType := file.Header.Get("Content-Type") result, err := UploadFromMultipartFile(h.service, file, opts)
if contentType == "" { if err != nil {
ext := filepath.Ext(file.Filename) return h.writeUploadError(c, file.Filename, err)
if detected := mime.TypeByExtension(ext); detected != "" { }
contentType = detected
} else { return c.JSON(http.StatusOK, UploadResponse{
contentType = "application/octet-stream" 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",
} }
} }
contentType = strings.TrimSpace(strings.Split(contentType, ";")[0])
validator := NewFileValidator(100*1024*1024, SupportedMimeTypes) message := "Files uploaded successfully"
if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil { if len(failed) > 0 {
if IsFileTooLarge(err) { message = "Some files uploaded successfully"
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{ }
Error: "File is too large",
Code: ErrCodeFileTooLarge, return c.JSON(http.StatusOK, BatchUploadResponse{
Message: "Maximum file size is 100 MB", Uploaded: uploaded,
}) Failed: failed,
} Message: message,
if IsUnsupportedType(err) { })
return c.JSON(http.StatusBadRequest, ErrorResponse{ }
Error: "Unsupported file type",
Code: ErrCodeUnsupported, func (h *Handler) buildUploadOptions(c echo.Context, uploadedBy string) UploadOptions {
Message: err.Error(), 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{ return c.JSON(http.StatusBadRequest, ErrorResponse{
Error: "Invalid file", Error: "Invalid file",
Code: ErrCodeInvalidInput, Code: ErrCodeInvalidInput,
@@ -157,49 +243,14 @@ func (h *Handler) Upload(c echo.Context) error {
}) })
} }
// Create upload options h.logger.Error("File upload failed", map[string]interface{}{
opts := UploadOptions{ "filename": filename,
Filename: file.Filename, "error": err.Error(),
ContentType: contentType, })
UploadedBy: uploadedBy, return c.JSON(http.StatusInternalServerError, ErrorResponse{
Category: category, Error: "Upload failed",
Tags: tags, Code: ErrCodeUploadFailed,
Description: description, Message: "Failed to upload file to storage",
MaxSize: 100 * 1024 * 1024, // 100 MB max
}
// Upload file
fileID, err := h.service.Upload("", file.Filename, src, opts)
if err != nil {
if IsFileTooLarge(err) {
h.logger.Warn("File upload failed: file too large", map[string]interface{}{
"filename": file.Filename,
"size": file.Size,
})
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{
Error: "File is too large",
Code: ErrCodeFileTooLarge,
Message: "Maximum file size is 100 MB",
})
}
h.logger.Error("File upload failed", map[string]interface{}{
"filename": file.Filename,
"error": err.Error(),
})
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: "Upload failed",
Code: ErrCodeUploadFailed,
Message: "Failed to upload file to storage",
})
}
return c.JSON(http.StatusOK, UploadResponse{
FileID: fileID,
Filename: file.Filename,
ContentType: contentType,
Size: file.Size,
Message: "File uploaded successfully",
}) })
} }
+128
View File
@@ -0,0 +1,128 @@
package filestore
import (
"errors"
"mime"
"mime/multipart"
"path/filepath"
"strings"
)
const (
// MaxUploadSize is the maximum allowed size per file (100 MB).
MaxUploadSize = 100 * 1024 * 1024
// MaxBatchUploadFiles is the maximum number of files per batch request.
MaxBatchUploadFiles = 20
)
// UploadedFileResult holds metadata for a successfully stored file.
type UploadedFileResult struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
// FailedUploadResult describes a file that could not be uploaded.
type FailedUploadResult struct {
Filename string `json:"filename"`
Error string `json:"error"`
Code string `json:"code,omitempty"`
}
// UploadFromMultipartFile validates and stores a single multipart file.
func UploadFromMultipartFile(service FileStoreService, file *multipart.FileHeader, opts UploadOptions) (UploadedFileResult, error) {
if file == nil {
return UploadedFileResult{}, NewError(ErrCodeInvalidInput, "file is required")
}
contentType := detectContentType(file)
validator := NewFileValidator(MaxUploadSize, SupportedMimeTypes)
if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil {
return UploadedFileResult{}, err
}
src, err := file.Open()
if err != nil {
return UploadedFileResult{}, NewErrorWithCause(ErrCodeUploadFailed, "failed to open uploaded file", err)
}
defer src.Close()
if opts.Filename == "" {
opts.Filename = file.Filename
}
if opts.ContentType == "" {
opts.ContentType = contentType
}
if opts.MaxSize == 0 {
opts.MaxSize = MaxUploadSize
}
fileID, err := service.Upload("", file.Filename, src, opts)
if err != nil {
return UploadedFileResult{}, err
}
return UploadedFileResult{
FileID: fileID,
Filename: file.Filename,
ContentType: contentType,
Size: file.Size,
}, nil
}
// UploadManyFromMultipart uploads multiple files and returns successes and failures.
func UploadManyFromMultipart(service FileStoreService, files []*multipart.FileHeader, opts UploadOptions) ([]UploadedFileResult, []FailedUploadResult) {
uploaded := make([]UploadedFileResult, 0, len(files))
failed := make([]FailedUploadResult, 0)
for _, file := range files {
result, err := UploadFromMultipartFile(service, file, opts)
if err != nil {
code := ErrCodeUploadFailed
var fsErr *FileStoreError
if errors.As(err, &fsErr) {
code = fsErr.Code
}
failed = append(failed, FailedUploadResult{
Filename: file.Filename,
Error: err.Error(),
Code: code,
})
continue
}
uploaded = append(uploaded, result)
}
return uploaded, failed
}
func detectContentType(file *multipart.FileHeader) string {
contentType := file.Header.Get("Content-Type")
if contentType == "" {
ext := filepath.Ext(file.Filename)
if detected := mime.TypeByExtension(ext); detected != "" {
contentType = detected
} else {
contentType = "application/octet-stream"
}
}
return strings.TrimSpace(strings.Split(contentType, ";")[0])
}
// CollectMultipartFiles returns file headers from the "files" field, or a single "file" if present.
func CollectMultipartFiles(form *multipart.Form) []*multipart.FileHeader {
if form == nil {
return nil
}
files := form.File["files"]
if len(files) > 0 {
return files
}
if file := form.File["file"]; len(file) > 0 {
return file
}
return nil
}