mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-08 04:43:12 +00:00
cb1b14b941
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
679 lines
24 KiB
Go
679 lines
24 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bufio"
|
|
"io"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"Noooste/garage-ui/internal/models"
|
|
"Noooste/garage-ui/internal/services"
|
|
|
|
"github.com/gofiber/fiber/v3"
|
|
)
|
|
|
|
// unsafeInlineContentTypes are MIME types that a browser can execute as
|
|
// JavaScript in the response's origin when rendered inline. Since the SPA is
|
|
// served from the same origin as the API, any uploader could otherwise plant
|
|
// stored XSS by uploading a file with one of these Content-Types.
|
|
var unsafeInlineContentTypes = map[string]struct{}{
|
|
"text/html": {},
|
|
"application/xhtml+xml": {},
|
|
"image/svg+xml": {},
|
|
"application/xml": {},
|
|
"text/xml": {},
|
|
"application/javascript": {},
|
|
"text/javascript": {},
|
|
}
|
|
|
|
// safeContentType rewrites Content-Types that the browser would treat as
|
|
// executable to application/octet-stream.
|
|
func safeContentType(ct string) string {
|
|
base := strings.TrimSpace(strings.ToLower(ct))
|
|
if i := strings.IndexByte(base, ';'); i >= 0 {
|
|
base = strings.TrimSpace(base[:i])
|
|
}
|
|
if _, bad := unsafeInlineContentTypes[base]; bad {
|
|
return "application/octet-stream"
|
|
}
|
|
return ct
|
|
}
|
|
|
|
// contentDispositionHeader builds an RFC 6266 / RFC 5987 Content-Disposition
|
|
// header value with the user-controlled object key safely encoded. Strips
|
|
// path components and control characters before emitting the ASCII fallback,
|
|
// then appends the percent-encoded UTF-8 filename*= for full fidelity.
|
|
func contentDispositionHeader(disposition, key string) string {
|
|
name := path.Base(key)
|
|
if name == "." || name == "/" || name == "" {
|
|
name = "download"
|
|
}
|
|
// ASCII-safe fallback: drop anything that could break the quoted value.
|
|
var asciiFallback strings.Builder
|
|
for _, r := range name {
|
|
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' || r > 0x7e {
|
|
asciiFallback.WriteByte('_')
|
|
continue
|
|
}
|
|
asciiFallback.WriteRune(r)
|
|
}
|
|
fallback := asciiFallback.String()
|
|
if fallback == "" {
|
|
fallback = "download"
|
|
}
|
|
encoded := url.PathEscape(name)
|
|
return disposition + "; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded
|
|
}
|
|
|
|
// ObjectHandler handles object-related HTTP requests.
|
|
type ObjectHandler struct {
|
|
s3Service services.S3Storage
|
|
}
|
|
|
|
// NewObjectHandler creates a new object handler.
|
|
func NewObjectHandler(s3Service services.S3Storage) *ObjectHandler {
|
|
return &ObjectHandler{
|
|
s3Service: s3Service,
|
|
}
|
|
}
|
|
|
|
// ListObjects lists objects in a bucket with optional filtering and pagination
|
|
//
|
|
// @Summary List objects in a bucket
|
|
// @Description Retrieves a list of objects and prefixes (folders) stored in the specified bucket, with optional filtering by prefix, pagination support, and max keys
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket to list objects from"
|
|
// @Param prefix query string false "Filter objects by prefix"
|
|
// @Param max_keys query int false "Maximum number of objects to return (default: 100)"
|
|
// @Param continuation_token query string false "Token for pagination to retrieve next page of results"
|
|
// @Success 200 {object} models.APIResponse{data=models.ObjectListResponse} "Successfully retrieved list of objects and prefixes"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to list objects"
|
|
// @Router /api/v1/buckets/{bucket}/objects [get]
|
|
func (h *ObjectHandler) ListObjects(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameter
|
|
bucketName := c.Params("bucket")
|
|
if bucketName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
|
)
|
|
}
|
|
|
|
// Get query parameters for filtering and pagination
|
|
prefix := c.Query("prefix", "")
|
|
continuationToken := c.Query("continuation_token", "")
|
|
|
|
maxKeysStr := c.Query("max_keys", "100")
|
|
maxKeys, err := strconv.Atoi(maxKeysStr)
|
|
if err != nil || maxKeys <= 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid max_keys parameter"),
|
|
)
|
|
}
|
|
|
|
// List objects in the bucket
|
|
objects, err := h.s3Service.ListObjects(ctx, bucketName, prefix, maxKeys, continuationToken)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeListFailed, "Failed to list objects: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
return c.JSON(models.SuccessResponse(objects))
|
|
}
|
|
|
|
// UploadObject uploads an object to a bucket
|
|
//
|
|
// @Summary Upload object to bucket
|
|
// @Description Uploads an object to the specified bucket using multipart/form-data
|
|
// @Tags Objects
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket to upload the object to"
|
|
// @Param file formData file true "File to upload"
|
|
// @Param key formData string false "Object key (path in bucket). If not provided, the filename will be used"
|
|
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Object uploaded successfully"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload object"
|
|
// @Router /api/v1/buckets/{bucket}/objects [post]
|
|
func (h *ObjectHandler) UploadObject(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameter
|
|
bucketName := c.Params("bucket")
|
|
if bucketName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
|
)
|
|
}
|
|
|
|
// Get file from multipart form
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "File is required: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
// Get object key (path in bucket)
|
|
key := c.FormValue("key")
|
|
if key == "" {
|
|
// Use filename as key if not provided
|
|
key = file.Filename
|
|
}
|
|
|
|
// Open the uploaded file
|
|
fileHandle, err := file.Open()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open uploaded file: "+err.Error()),
|
|
)
|
|
}
|
|
defer fileHandle.Close()
|
|
|
|
// Get content type
|
|
contentType := file.Header.Get("Content-Type")
|
|
|
|
// Upload to Garage
|
|
uploadResult, err := h.s3Service.UploadObject(ctx, bucketName, key, fileHandle, contentType)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to upload object: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(uploadResult))
|
|
}
|
|
|
|
// CreateDirectory creates an empty directory marker in a bucket.
|
|
//
|
|
// @Summary Create directory in bucket
|
|
// @Description Creates a zero-byte object whose key ends with "/" so that S3 clients display it as an empty folder.
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket"
|
|
// @Param request body object{key=string} true "Directory key (must end with '/')"
|
|
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadResponse} "Directory created"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to create directory"
|
|
// @Router /api/v1/buckets/{bucket}/directories [post]
|
|
func (h *ObjectHandler) CreateDirectory(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
bucketName := c.Params("bucket")
|
|
if bucketName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
|
)
|
|
}
|
|
|
|
var req struct {
|
|
Key string `json:"key"`
|
|
}
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
key := strings.TrimLeft(req.Key, "/")
|
|
if key == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Directory key is required"),
|
|
)
|
|
}
|
|
if !strings.HasSuffix(key, "/") {
|
|
key += "/"
|
|
}
|
|
|
|
result, err := h.s3Service.CreateDirectoryMarker(ctx, bucketName, key)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to create directory: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(models.SuccessResponse(result))
|
|
}
|
|
|
|
// GetObject retrieves an object from a bucket
|
|
//
|
|
// @Summary Get object from bucket
|
|
// @Description Retrieves an object stored in the specified bucket
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce application/octet-stream
|
|
// @Param bucket path string true "Name of the bucket containing the object"
|
|
// @Param key path string true "Key (path) of the object"
|
|
// @Param download query bool false "Set to true to download the object as an attachment"
|
|
// @Success 200 {file} binary "Successfully retrieved the object"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
|
// @Router /api/v1/buckets/{bucket}/objects/{key} [get]
|
|
func (h *ObjectHandler) GetObject(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameters
|
|
bucketName := c.Params("bucket")
|
|
|
|
// Get object key from locals (set by route handler) or from params
|
|
key, ok := c.Locals("objectKey").(string)
|
|
if !ok || key == "" {
|
|
key = c.Params("key")
|
|
}
|
|
|
|
if bucketName == "" || key == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
|
)
|
|
}
|
|
|
|
// Get object from Garage
|
|
body, objectInfo, err := h.s3Service.GetObject(ctx, bucketName, key)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(
|
|
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
// The uploader controls Content-Type. Rewrite executable MIME types to
|
|
// application/octet-stream and always disable sniffing so stored HTML/SVG
|
|
// cannot run as XSS in the SPA origin when fetched inline.
|
|
c.Set("Content-Type", safeContentType(objectInfo.ContentType))
|
|
c.Set("X-Content-Type-Options", "nosniff")
|
|
c.Set("Content-Length", strconv.FormatInt(objectInfo.Size, 10))
|
|
c.Set("ETag", objectInfo.ETag)
|
|
c.Set("Last-Modified", objectInfo.LastModified.Format(time.RFC1123))
|
|
|
|
// The object key is attacker-controlled — build the header via the safe
|
|
// RFC 6266 helper to avoid quote/semicolon injection into filename=.
|
|
disposition := "inline"
|
|
if c.Query("download") == "true" {
|
|
disposition = "attachment"
|
|
}
|
|
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
|
|
|
// Stream the object body to the client without buffering the entire file
|
|
return c.SendStreamWriter(func(w *bufio.Writer) {
|
|
defer body.Close()
|
|
io.Copy(w, body)
|
|
})
|
|
}
|
|
|
|
// DeleteObject deletes an object from a bucket
|
|
//
|
|
// @Summary Delete object from bucket
|
|
// @Description Deletes an object stored in the specified bucket
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket containing the object"
|
|
// @Param key path string true "Key (path) of the object"
|
|
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteResponse} "Successfully deleted the object"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete object"
|
|
// @Router /api/v1/buckets/{bucket}/objects/{key} [delete]
|
|
func (h *ObjectHandler) DeleteObject(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameters
|
|
bucketName := c.Params("bucket")
|
|
|
|
// Get object key from locals (set by route handler) or from params
|
|
key, ok := c.Locals("objectKey").(string)
|
|
if !ok || key == "" {
|
|
key = c.Params("key")
|
|
}
|
|
|
|
if bucketName == "" || key == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
|
)
|
|
}
|
|
|
|
// Check if object exists
|
|
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
if !exists {
|
|
return c.Status(fiber.StatusNotFound).JSON(
|
|
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
|
)
|
|
}
|
|
|
|
// Delete the object
|
|
if err := h.s3Service.DeleteObject(ctx, bucketName, key); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete object: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
// Return success response
|
|
response := models.ObjectDeleteResponse{
|
|
Bucket: bucketName,
|
|
Key: key,
|
|
Deleted: true,
|
|
}
|
|
|
|
return c.JSON(models.SuccessResponse(response))
|
|
}
|
|
|
|
// GetObjectMetadata returns metadata for an object without downloading it
|
|
//
|
|
// @Summary Get object metadata
|
|
// @Description Retrieves metadata information about an object without downloading the actual content
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket containing the object"
|
|
// @Param key path string true "Key (path) of the object"
|
|
// @Success 200 {object} models.APIResponse{data=models.ObjectInfo} "Successfully retrieved object metadata"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
|
// @Router /api/v1/buckets/{bucket}/objects/{key}/metadata [get]
|
|
func (h *ObjectHandler) GetObjectMetadata(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameters
|
|
bucketName := c.Params("bucket")
|
|
|
|
// Get object key from locals (set by route handler) or from params
|
|
key, ok := c.Locals("objectKey").(string)
|
|
if !ok || key == "" {
|
|
key = c.Params("key")
|
|
}
|
|
|
|
if bucketName == "" || key == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
|
)
|
|
}
|
|
|
|
// Get object metadata
|
|
metadata, err := h.s3Service.GetObjectMetadata(ctx, bucketName, key)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(
|
|
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
return c.JSON(models.SuccessResponse(metadata))
|
|
}
|
|
|
|
// GetPresignedURL generates a pre-signed URL for accessing an object
|
|
//
|
|
// @Summary Get pre-signed URL for object
|
|
// @Description Generates a pre-signed URL that allows temporary access to the specified object
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket containing the object"
|
|
// @Param key path string true "Key (path) of the object"
|
|
// @Param expires_in query int false "Expiration time in seconds for the pre-signed URL (default: 3600 seconds)"
|
|
// @Success 200 {object} models.APIResponse{data=models.PresignedURLResponse} "Successfully generated pre-signed URL"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Object not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to generate pre-signed URL"
|
|
// @Router /api/v1/buckets/{bucket}/objects/{key}/presigned-url [get]
|
|
func (h *ObjectHandler) GetPresignedURL(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameters
|
|
bucketName := c.Params("bucket")
|
|
|
|
// Get object key from locals (set by route handler) or from params
|
|
key, ok := c.Locals("objectKey").(string)
|
|
if !ok || key == "" {
|
|
key = c.Params("key")
|
|
}
|
|
|
|
if bucketName == "" || key == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name and object key are required"),
|
|
)
|
|
}
|
|
|
|
// Get expiration time from query parameter (default: 1 hour)
|
|
expiresInStr := c.Query("expires_in", "3600")
|
|
expiresIn, err := strconv.ParseInt(expiresInStr, 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
// Validate expiration time (1 second to 7 days)
|
|
if expiresIn <= 0 || expiresIn > 604800 { // Max 7 days
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid expiration time (must be between 1 and 604800 seconds)"),
|
|
)
|
|
}
|
|
|
|
// Check if object exists
|
|
exists, err := h.s3Service.ObjectExists(ctx, bucketName, key)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to check object existence: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
if !exists {
|
|
return c.Status(fiber.StatusNotFound).JSON(
|
|
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found"),
|
|
)
|
|
}
|
|
|
|
// Generate pre-signed URL
|
|
url, err := h.s3Service.GetPresignedURL(ctx, bucketName, key, time.Duration(expiresIn)*time.Second)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to generate pre-signed URL: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
response := models.PresignedURLResponse{
|
|
URL: url,
|
|
ExpiresIn: expiresIn,
|
|
Bucket: bucketName,
|
|
Key: key,
|
|
}
|
|
|
|
return c.JSON(models.SuccessResponse(response))
|
|
}
|
|
|
|
// DeleteMultipleObjects deletes multiple objects from a bucket
|
|
//
|
|
// @Summary Delete multiple objects from bucket
|
|
// @Description Deletes multiple objects stored in the specified bucket
|
|
// @Tags Objects
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket containing the objects"
|
|
// @Param request body object{keys=[]string,prefix=string} true "List of object keys to delete and optional prefix for path context"
|
|
// @Success 200 {object} models.APIResponse{data=models.ObjectDeleteMultipleResponse} "Successfully deleted the objects"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to delete objects"
|
|
// @Router /api/v1/buckets/{bucket}/objects/delete-multiple [post]
|
|
func (h *ObjectHandler) DeleteMultipleObjects(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameter
|
|
bucketName := c.Params("bucket")
|
|
if bucketName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
|
)
|
|
}
|
|
|
|
// Parse request body to get keys and optional prefix
|
|
var req struct {
|
|
Keys []string `json:"keys"`
|
|
Prefix string `json:"prefix,omitempty"`
|
|
}
|
|
if err := c.Bind().JSON(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Invalid request body: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
if len(req.Keys) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key is required"),
|
|
)
|
|
}
|
|
|
|
// Delete multiple objects
|
|
if err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
response := models.ObjectDeleteMultipleResponse{
|
|
Bucket: bucketName,
|
|
Deleted: len(req.Keys),
|
|
Keys: req.Keys,
|
|
}
|
|
|
|
return c.JSON(models.SuccessResponse(response))
|
|
}
|
|
|
|
// UploadMultipleObjects uploads multiple objects to a bucket
|
|
//
|
|
// @Summary Upload multiple objects to bucket
|
|
// @Description Uploads multiple objects to the specified bucket using multipart/form-data. Accepts unlimited number of files and handles them in a loop.
|
|
// @Tags Objects
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param bucket path string true "Name of the bucket to upload the objects to"
|
|
// @Param files formData file true "Files to upload (can be multiple)"
|
|
// @Success 201 {object} models.APIResponse{data=models.ObjectUploadMultipleResponse} "Objects uploaded successfully (including partial failures)"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Invalid request parameters"
|
|
// @Failure 404 {object} models.APIResponse{error=models.APIError} "Bucket not found"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to upload objects"
|
|
// @Router /api/v1/buckets/{bucket}/objects/upload-multiple [post]
|
|
func (h *ObjectHandler) UploadMultipleObjects(c fiber.Ctx) error {
|
|
ctx := c.Context()
|
|
|
|
// Get bucket name from URL parameter
|
|
bucketName := c.Params("bucket")
|
|
if bucketName == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Bucket name is required"),
|
|
)
|
|
}
|
|
|
|
// Parse multipart form to get all files
|
|
form, err := c.MultipartForm()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Failed to parse multipart form: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
files := form.File["files"]
|
|
if len(files) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "At least one file is required"),
|
|
)
|
|
}
|
|
|
|
// Prepare upload data structure
|
|
uploadFiles := make([]struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}, len(files))
|
|
|
|
// Open all files and prepare for upload
|
|
for i, fileHeader := range files {
|
|
file, err := fileHeader.Open()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeUploadFailed, "Failed to open file "+fileHeader.Filename+": "+err.Error()),
|
|
)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Use filename as the key
|
|
key := fileHeader.Filename
|
|
contentType := fileHeader.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
uploadFiles[i] = struct {
|
|
Key string
|
|
Body io.Reader
|
|
ContentType string
|
|
}{
|
|
Key: key,
|
|
Body: file,
|
|
ContentType: contentType,
|
|
}
|
|
}
|
|
|
|
// Upload all files using the service method
|
|
results := h.s3Service.UploadMultipleObjects(ctx, bucketName, uploadFiles)
|
|
|
|
// Process results and categorize successes and failures
|
|
var successFiles []models.ObjectUploadResult
|
|
var failedFiles []models.ObjectUploadFailedResult
|
|
successCount := 0
|
|
failureCount := 0
|
|
|
|
for _, result := range results {
|
|
if result.Success {
|
|
successCount++
|
|
successFiles = append(successFiles, models.ObjectUploadResult{
|
|
Key: result.Key,
|
|
ETag: result.ETag,
|
|
Size: result.Size,
|
|
ContentType: result.ContentType,
|
|
})
|
|
} else {
|
|
failureCount++
|
|
failedFiles = append(failedFiles, models.ObjectUploadFailedResult{
|
|
Key: result.Key,
|
|
Error: result.Error.Error(),
|
|
ContentType: result.ContentType,
|
|
})
|
|
}
|
|
}
|
|
|
|
response := models.ObjectUploadMultipleResponse{
|
|
Bucket: bucketName,
|
|
TotalFiles: len(files),
|
|
SuccessCount: successCount,
|
|
FailureCount: failureCount,
|
|
SuccessFiles: successFiles,
|
|
FailedFiles: failedFiles,
|
|
}
|
|
|
|
// Return 201 if all succeeded, 207 (Multi-Status) if partial success, 500 if all failed
|
|
statusCode := fiber.StatusCreated
|
|
if failureCount > 0 && successCount > 0 {
|
|
statusCode = fiber.StatusMultiStatus // 207
|
|
} else if failureCount > 0 && successCount == 0 {
|
|
statusCode = fiber.StatusInternalServerError
|
|
}
|
|
|
|
return c.Status(statusCode).JSON(models.SuccessResponse(response))
|
|
}
|