feat: add CreateDirectory endpoint and S3 directory marker support

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-19 17:10:06 +02:00
parent 03bb3e8fb6
commit cb1b14b941
7 changed files with 138 additions and 5 deletions
+52
View File
@@ -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
+3
View File
@@ -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 {
+1
View File
@@ -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
@@ -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 {
+68 -4
View File
@@ -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
+1 -1
View File
@@ -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;
+4
View File
@@ -276,6 +276,10 @@ export const objectsApi = {
};
},
createDirectory: async (bucket: string, key: string): Promise<void> => {
await api.post(`/v1/buckets/${bucket}/directories`, { key });
},
upload: async (bucket: string, key: string, file: File, onProgress?: (progress: number) => void): Promise<void> => {
const formData = new FormData();
formData.append('file', file);