mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
c0976a1e1d
* feat(preview): implement object preview functionality with token-based access * docs: update README to include inline object preview feature * test: add range read error handling and attachment content disposition tests
844 lines
31 KiB
Go
844 lines
31 KiB
Go
package handlers
|
|
|
|
import (
|
|
"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
|
|
}
|
|
|
|
// PreviewTokenMinter mints signed single-object preview tokens.
|
|
// auth.Service satisfies it.
|
|
type PreviewTokenMinter interface {
|
|
MintPreviewToken(bucket, key string, ttl time.Duration) (string, time.Time, error)
|
|
}
|
|
|
|
// previewTokenTTL is long enough that seeking mid-playback keeps working.
|
|
// The frontend mints a fresh URL when a token expires.
|
|
const previewTokenTTL = time.Hour
|
|
|
|
// ObjectHandler handles object-related HTTP requests.
|
|
type ObjectHandler struct {
|
|
s3Service services.S3Storage
|
|
previewTokens PreviewTokenMinter
|
|
}
|
|
|
|
// NewObjectHandler creates a new object handler.
|
|
func NewObjectHandler(s3Service services.S3Storage, previewTokens PreviewTokenMinter) *ObjectHandler {
|
|
return &ObjectHandler{
|
|
s3Service: s3Service,
|
|
previewTokens: previewTokens,
|
|
}
|
|
}
|
|
|
|
// 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 search query string false "Recursively search object keys under prefix by case-insensitive substring (best-effort; bypasses max_keys and continuation_token)"
|
|
// @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", "")
|
|
|
|
// Search mode: a recursive, best-effort substring search across the whole
|
|
// subtree under prefix. S3/Garage has no server-side substring search, so
|
|
// the backend scans and filters. This bypasses page-token pagination and
|
|
// max_keys, see S3Service.SearchObjects -> pagination is handled frontend side.
|
|
if search := c.Query("search", ""); search != "" {
|
|
results, err := h.s3Service.SearchObjects(ctx, bucketName, prefix, search)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeListFailed, "Failed to search objects: "+err.Error()),
|
|
)
|
|
}
|
|
return c.JSON(models.SuccessResponse(results))
|
|
}
|
|
|
|
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 {
|
|
bucketName := c.Params("bucket")
|
|
|
|
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"),
|
|
)
|
|
}
|
|
|
|
// Range requests stream a partial body so media elements can seek.
|
|
if rangeHeader := c.Get("Range"); rangeHeader != "" {
|
|
return h.getObjectRange(c, bucketName, key, rangeHeader)
|
|
}
|
|
return h.serveFullObject(c, bucketName, key)
|
|
}
|
|
|
|
// serveFullObject streams the whole object with a 200, the pre-Range behavior.
|
|
func (h *ObjectHandler) serveFullObject(c fiber.Ctx, bucketName, key string) error {
|
|
ctx := c.Context()
|
|
|
|
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("Accept-Ranges", "bytes")
|
|
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))
|
|
|
|
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
|
|
// streaming writer variant forces fasthttp into unknown-length chunked
|
|
// transfer, dropping the header we just set above.
|
|
return c.SendStream(body, int(objectInfo.Size))
|
|
}
|
|
|
|
// getObjectRange serves a single-range request with 206 Partial Content.
|
|
// Malformed and multi-range headers fall back to the full 200 response.
|
|
func (h *ObjectHandler) getObjectRange(c fiber.Ctx, bucketName, key, rangeHeader string) error {
|
|
ctx := c.Context()
|
|
|
|
info, 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()),
|
|
)
|
|
}
|
|
|
|
rng, unsatisfiable := parseRangeHeader(rangeHeader, info.Size)
|
|
if unsatisfiable {
|
|
c.Set("Accept-Ranges", "bytes")
|
|
c.Set("Content-Range", "bytes */"+strconv.FormatInt(info.Size, 10))
|
|
return c.SendStatus(fiber.StatusRequestedRangeNotSatisfiable)
|
|
}
|
|
if rng == nil {
|
|
return h.serveFullObject(c, bucketName, key)
|
|
}
|
|
|
|
body, err := h.s3Service.GetObjectRange(ctx, bucketName, key, rng.start, rng.end)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(
|
|
models.ErrorResponse(models.ErrCodeObjectNotFound, "Object not found: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
c.Set("Content-Type", safeContentType(info.ContentType))
|
|
c.Set("X-Content-Type-Options", "nosniff")
|
|
c.Set("Accept-Ranges", "bytes")
|
|
c.Set("Content-Length", strconv.FormatInt(rng.end-rng.start+1, 10))
|
|
c.Set("Content-Range", "bytes "+strconv.FormatInt(rng.start, 10)+"-"+strconv.FormatInt(rng.end, 10)+"/"+strconv.FormatInt(info.Size, 10))
|
|
c.Set("ETag", info.ETag)
|
|
c.Set("Last-Modified", info.LastModified.Format(time.RFC1123))
|
|
|
|
disposition := "inline"
|
|
if c.Query("download") == "true" {
|
|
disposition = "attachment"
|
|
}
|
|
c.Set("Content-Disposition", contentDispositionHeader(disposition, key))
|
|
|
|
c.Status(fiber.StatusPartialContent)
|
|
// SendStream (not SendStreamWriter) keeps the declared Content-Length: the
|
|
// streaming writer variant forces fasthttp into unknown-length chunked
|
|
// transfer, dropping the header we just set above.
|
|
return c.SendStream(body, int(rng.end-rng.start+1))
|
|
}
|
|
|
|
// 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()),
|
|
)
|
|
}
|
|
|
|
c.Set("Accept-Ranges", "bytes")
|
|
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))
|
|
}
|
|
|
|
// GetPreviewURL mints a short-lived tokenized URL for streaming this object
|
|
//
|
|
// @Summary Get a tokenized preview URL for an object
|
|
// @Description Returns a relative URL carrying a short-lived token that authorizes streaming this object. Media elements cannot send an Authorization header, so the token rides in the URL instead.
|
|
// @Tags Objects
|
|
// @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.PreviewURLResponse} "Preview URL minted"
|
|
// @Failure 400 {object} models.APIResponse{error=models.APIError} "Bucket name and object key are required"
|
|
// @Failure 500 {object} models.APIResponse{error=models.APIError} "Failed to mint the preview token"
|
|
// @Router /api/v1/buckets/{bucket}/objects/{key}/preview-url [get]
|
|
func (h *ObjectHandler) GetPreviewURL(c fiber.Ctx) error {
|
|
bucketName := c.Params("bucket")
|
|
|
|
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"),
|
|
)
|
|
}
|
|
|
|
token, expiresAt, err := h.previewTokens.MintPreviewToken(bucketName, key, previewTokenTTL)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeInternalError, "Failed to mint the preview token: "+err.Error()),
|
|
)
|
|
}
|
|
|
|
previewURL := "/api/v1/buckets/" + url.PathEscape(bucketName) +
|
|
"/objects/" + url.PathEscape(key) + "?pt=" + url.QueryEscape(token)
|
|
|
|
return c.JSON(models.SuccessResponse(models.PreviewURLResponse{
|
|
URL: previewURL,
|
|
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
|
}))
|
|
}
|
|
|
|
// 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,prefixes=[]string} true "Object keys to delete and/or folder prefixes to delete recursively"
|
|
// @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. "keys" are concrete objects to delete; "prefixes" are
|
|
// folders to delete recursively (every object stored under the prefix).
|
|
var req struct {
|
|
Keys []string `json:"keys"`
|
|
Prefixes []string `json:"prefixes,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 && len(req.Prefixes) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "At least one key or prefix is required"),
|
|
)
|
|
}
|
|
|
|
// Validate and normalize folder prefixes before running an irreversible
|
|
// recursive delete on a public endpoint. A blank prefix would match the
|
|
// entire bucket, and a prefix without a trailing slash (e.g. "photos/2024")
|
|
// would also match sibling keys such as "photos/2024-old/...". Reject blanks
|
|
// with a 4XX and force a trailing slash so a prefix only ever deletes the
|
|
// objects inside its own folder.
|
|
prefixes := make([]string, 0, len(req.Prefixes))
|
|
for _, p := range req.Prefixes {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(
|
|
models.ErrorResponse(models.ErrCodeBadRequest, "Prefix must not be blank"),
|
|
)
|
|
}
|
|
if !strings.HasSuffix(trimmed, "/") {
|
|
trimmed += "/"
|
|
}
|
|
prefixes = append(prefixes, trimmed)
|
|
}
|
|
|
|
deleted := 0
|
|
|
|
// Delete the individually selected objects in a single batch call.
|
|
if len(req.Keys) > 0 {
|
|
n, err := h.s3Service.DeleteMultipleObjects(ctx, bucketName, req.Keys)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete objects: "+err.Error()),
|
|
)
|
|
}
|
|
deleted += n
|
|
}
|
|
|
|
// Recursively delete every object under each selected folder prefix.
|
|
for _, prefix := range prefixes {
|
|
n, err := h.s3Service.DeleteObjectsByPrefix(ctx, bucketName, prefix)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(
|
|
models.ErrorResponse(models.ErrCodeDeleteFailed, "Failed to delete folder "+prefix+": "+err.Error()),
|
|
)
|
|
}
|
|
deleted += n
|
|
}
|
|
|
|
response := models.ObjectDeleteMultipleResponse{
|
|
Bucket: bucketName,
|
|
Deleted: deleted,
|
|
Keys: req.Keys,
|
|
Prefixes: prefixes,
|
|
}
|
|
|
|
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))
|
|
}
|