From cb1b14b9415b0ae331d0afdd6468870091720ad3 Mon Sep 17 00:00:00 2001 From: Noooste <83548733+Noooste@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:10:06 +0200 Subject: [PATCH] feat: add CreateDirectory endpoint and S3 directory marker support Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com> --- backend/internal/handlers/objects.go | 52 ++++++++++++++++ backend/internal/routes/routes.go | 3 + backend/internal/services/interfaces.go | 1 + backend/internal/services/mocks/s3_mock.go | 9 +++ backend/internal/services/s3.go | 72 ++++++++++++++++++++-- frontend/src/hooks/useBucketObjects.ts | 2 +- frontend/src/lib/api.ts | 4 ++ 7 files changed, 138 insertions(+), 5 deletions(-) diff --git a/backend/internal/handlers/objects.go b/backend/internal/handlers/objects.go index 8375089..ab43050 100644 --- a/backend/internal/handlers/objects.go +++ b/backend/internal/handlers/objects.go @@ -194,6 +194,58 @@ func (h *ObjectHandler) UploadObject(c fiber.Ctx) 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 diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 67b1a96..df71591 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -72,6 +72,9 @@ func SetupRoutes( objects.Post("/delete-multiple", objectHandler.DeleteMultipleObjects) // Delete multiple objects } + // Directory routes (zero-byte directory markers) + api.Post("/buckets/:bucket/directories", objectHandler.CreateDirectory) + // Fiber v3 does not auto-decode wildcard params; fall back to the raw // value when QueryUnescape fails. decodeObjectKey := func(c fiber.Ctx) string { diff --git a/backend/internal/services/interfaces.go b/backend/internal/services/interfaces.go index f8a7856..8785369 100644 --- a/backend/internal/services/interfaces.go +++ b/backend/internal/services/interfaces.go @@ -48,6 +48,7 @@ type AdminService interface { type S3Storage interface { ListObjects(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) UploadObject(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) + CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) ObjectExists(ctx context.Context, bucketName, key string) (bool, error) DeleteObject(ctx context.Context, bucketName, key string) error diff --git a/backend/internal/services/mocks/s3_mock.go b/backend/internal/services/mocks/s3_mock.go index 4ddeaf8..ac46ad8 100644 --- a/backend/internal/services/mocks/s3_mock.go +++ b/backend/internal/services/mocks/s3_mock.go @@ -25,6 +25,7 @@ var _ services.S3Storage = (*S3Mock)(nil) type S3Mock struct { ListObjectsFn func(ctx context.Context, bucketName, prefix string, maxKeys int, continuationToken string) (*models.ObjectListResponse, error) UploadObjectFn func(ctx context.Context, bucketName, key string, body io.Reader, contentType string) (*models.ObjectUploadResponse, error) + CreateDirectoryMarkerFn func(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) GetObjectFn func(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) ObjectExistsFn func(ctx context.Context, bucketName, key string) (bool, error) DeleteObjectFn func(ctx context.Context, bucketName, key string) error @@ -62,6 +63,14 @@ func (m *S3Mock) UploadObject(ctx context.Context, bucketName, key string, body return m.UploadObjectFn(ctx, bucketName, key, body, contentType) } +func (m *S3Mock) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) { + m.record("CreateDirectoryMarker", bucketName, key) + if m.CreateDirectoryMarkerFn == nil { + return nil, s3NotConfigured("CreateDirectoryMarker") + } + return m.CreateDirectoryMarkerFn(ctx, bucketName, key) +} + func (m *S3Mock) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { m.record("GetObject", bucketName, key) if m.GetObjectFn == nil { diff --git a/backend/internal/services/s3.go b/backend/internal/services/s3.go index db51829..8755986 100644 --- a/backend/internal/services/s3.go +++ b/backend/internal/services/s3.go @@ -1,6 +1,7 @@ package services import ( + "bytes" "context" "fmt" "io" @@ -218,9 +219,28 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, return nil, fmt.Errorf("failed to list objects in bucket %s: %w", bucketName, err) } + // Drop directory marker objects (zero-byte keys ending in "/"). Garage + // returns them in Contents, but the UI renders folders from Prefixes — a + // marker shown as both a folder and a file is confusing. Any marker not + // already covered by a CommonPrefix is promoted to Prefixes below. + contents := make([]minio.ObjectInfo, 0, len(result.Contents)) + markerKeys := make([]string, 0) + for _, obj := range result.Contents { + if strings.HasSuffix(obj.Key, "/") && obj.Size == 0 { + // A marker whose key equals the current listing prefix is the + // folder itself — drop it entirely so it doesn't render as a + // nameless child of itself. + if obj.Key != prefix { + markerKeys = append(markerKeys, obj.Key) + } + continue + } + contents = append(contents, obj) + } + // Process objects from result.Contents // Note: ListObjectsV2 doesn't return ContentType, so we need to fetch it separately - objects := make([]models.ObjectInfo, len(result.Contents)) + objects := make([]models.ObjectInfo, len(contents)) // Use goroutines to fetch ContentType concurrently for better performance type statResult struct { @@ -231,7 +251,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, statChan := make(chan statResult, len(result.Contents)) - for i, obj := range result.Contents { + for i, obj := range contents { go func(idx int, objKey string) { // Fetch object metadata to get ContentType stat, err := client.StatObject(ctx, bucketName, objKey, minio.StatObjectOptions{}) @@ -254,7 +274,7 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, } // Collect results from goroutines - for range result.Contents { + for range contents { res := <-statChan if res.err == nil { objects[res.index].ContentType = res.contentType @@ -264,9 +284,20 @@ func (s *S3Service) ListObjects(ctx context.Context, bucketName, prefix string, close(statChan) // Process folders from result.CommonPrefixes - prefixList := make([]string, 0, len(result.CommonPrefixes)) + prefixList := make([]string, 0, len(result.CommonPrefixes)+len(markerKeys)) + seen := make(map[string]struct{}, len(result.CommonPrefixes)) for _, p := range result.CommonPrefixes { prefixList = append(prefixList, p.Prefix) + seen[p.Prefix] = struct{}{} + } + // Promote filtered directory markers into Prefixes so empty folders still + // appear in the listing. + for _, k := range markerKeys { + if _, ok := seen[k]; ok { + continue + } + prefixList = append(prefixList, k) + seen[k] = struct{}{} } return &models.ObjectListResponse{ @@ -314,6 +345,39 @@ func (s *S3Service) UploadObject(ctx context.Context, bucketName, key string, bo }, nil } +// CreateDirectoryMarker creates a zero-byte object whose key ends with "/". +// Garage rejects the streaming path (size=-1) with "Empty body" because the +// MinIO client switches to multipart upload, which requires payload. Passing +// size=0 forces a single PutObject request with Content-Length: 0, which +// Garage accepts as a directory marker. +func (s *S3Service) CreateDirectoryMarker(ctx context.Context, bucketName, key string) (*models.ObjectUploadResponse, error) { + client, err := s.getMinioClient(ctx, bucketName) + if err != nil { + return nil, fmt.Errorf("failed to get MinIO client for bucket %s: %w", bucketName, err) + } + + opts := minio.PutObjectOptions{ContentType: "application/x-directory"} + + var info minio.UploadInfo + retryConfig := utils.DefaultRetryConfig() + err = utils.RetryWithBackoff(ctx, retryConfig, func() error { + var uploadErr error + info, uploadErr = client.PutObject(ctx, bucketName, key, bytes.NewReader(nil), 0, opts) + return uploadErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create directory %s in bucket %s: %w", key, bucketName, err) + } + + return &models.ObjectUploadResponse{ + Bucket: bucketName, + Key: key, + ETag: info.ETag, + Size: info.Size, + ContentType: opts.ContentType, + }, nil +} + // GetObject retrieves an object from a bucket func (s *S3Service) GetObject(ctx context.Context, bucketName, key string) (io.ReadCloser, *models.ObjectInfo, error) { // Get bucket-specific MinIO client diff --git a/frontend/src/hooks/useBucketObjects.ts b/frontend/src/hooks/useBucketObjects.ts index 5862698..68ccfff 100644 --- a/frontend/src/hooks/useBucketObjects.ts +++ b/frontend/src/hooks/useBucketObjects.ts @@ -182,7 +182,7 @@ export function useBucketObjects(bucketName: string | null, currentPath: string try { const dirKey = currentPath ? `${currentPath}${dirName}/` : `${dirName}/`; - await objectsApi.upload(bucketName, dirKey, new File([], '.keep')); + await objectsApi.createDirectory(bucketName, dirKey); toast.success(`Directory "${dirName}" created successfully`); await fetchObjects(currentContinuationToken, true); return true; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b755bf1..9963098 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -276,6 +276,10 @@ export const objectsApi = { }; }, + createDirectory: async (bucket: string, key: string): Promise => { + await api.post(`/v1/buckets/${bucket}/directories`, { key }); + }, + upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise => { const formData = new FormData(); formData.append('file', file);