diff --git a/internal/s3/client.go b/internal/s3/client.go index eb427dd..011be1c 100644 --- a/internal/s3/client.go +++ b/internal/s3/client.go @@ -189,6 +189,34 @@ func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadID string) return err } +// DeleteObject deletes a single object from S3/R2 by key. +func (c *Client) DeleteObject(ctx context.Context, key string) error { + _, err := c.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + return err +} + +// ListByPrefix returns all object keys matching the given prefix. +func (c *Client) ListByPrefix(ctx context.Context, prefix string) ([]string, error) { + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucket), + Prefix: aws.String(prefix), + } + + result, err := c.s3Client.ListObjectsV2(ctx, input) + if err != nil { + return nil, err + } + + keys := make([]string, 0, len(result.Contents)) + for _, obj := range result.Contents { + keys = append(keys, *obj.Key) + } + return keys, nil +} + // PartInfo represents a completed part for multipart upload type PartInfo struct { ETag string diff --git a/internal/upload/handlers.go b/internal/upload/handlers.go index 3440728..7483729 100644 --- a/internal/upload/handlers.go +++ b/internal/upload/handlers.go @@ -186,6 +186,51 @@ func (h *Handler) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(response) } +// HandleDeleteAsset handles DELETE /v1/assets/{profile}/{key_base} +// Deletes the original file and all generated thumbnails for an asset. +func (h *Handler) HandleDeleteAsset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + h.writeError(w, http.StatusMethodNotAllowed, ErrBadRequest, "Method not allowed", "") + return + } + + // Extract profile and key_base from URL path + path := strings.TrimPrefix(r.URL.Path, "/v1/assets/") + slashIdx := strings.Index(path, "/") + if slashIdx < 1 || slashIdx == len(path)-1 { + h.writeError(w, http.StatusBadRequest, ErrBadRequest, "Invalid URL format", "Expected /v1/assets/{profile}/{key_base}") + return + } + + profileName := path[:slashIdx] + keyBase := path[slashIdx+1:] + + // Look up profile config + profile := h.storageConfig.GetProfile(profileName) + if profile == nil { + h.writeError(w, http.StatusBadRequest, ErrBadRequest, fmt.Sprintf("Unknown profile: %s", profileName), "") + return + } + + // Delete the original + thumbnails + deleted, err := h.uploadService.DeleteAsset(h.ctx, profile, keyBase) + if err != nil { + fmt.Printf("Delete asset error: %v\n", err) + h.writeError(w, http.StatusInternalServerError, ErrStorageDenied, fmt.Sprintf("Failed to delete asset: %v", err), "") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + response := map[string]any{ + "status": "deleted", + "profile": profileName, + "key_base": keyBase, + "objects_deleted": deleted, + } + _ = json.NewEncoder(w).Encode(response) +} + // writeError writes a standardized error response func (h *Handler) writeError(w http.ResponseWriter, statusCode int, code, message, hint string) { errorResp := ErrorResponse{ diff --git a/internal/upload/interfaces.go b/internal/upload/interfaces.go index 15d1003..c0ba5df 100644 --- a/internal/upload/interfaces.go +++ b/internal/upload/interfaces.go @@ -14,4 +14,6 @@ type S3Client interface { PresignUploadPart(ctx context.Context, key, uploadID string, partNumber int32, expires time.Duration) (string, error) CompleteMultipartUpload(ctx context.Context, key, uploadID string, parts []s3.PartInfo) error AbortMultipartUpload(ctx context.Context, key, uploadID string) error + DeleteObject(ctx context.Context, key string) error + ListByPrefix(ctx context.Context, prefix string) ([]string, error) } \ No newline at end of file diff --git a/internal/upload/service.go b/internal/upload/service.go index 236b61a..b20e54f 100644 --- a/internal/upload/service.go +++ b/internal/upload/service.go @@ -236,6 +236,42 @@ func (s *Service) AbortMultipartUpload(ctx context.Context, objectKey, uploadID return s.s3Client.AbortMultipartUpload(ctx, objectKey, uploadID) } +// DeleteAsset deletes an asset's original file and all generated thumbnails from R2. +// It resolves the storage paths from the profile config, handling sharding if enabled. +func (s *Service) DeleteAsset(ctx context.Context, profile *config.Profile, keyBase string) (int, error) { + // Build the original object key (same logic as upload) + shard := "" + if profile.EnableSharding { + shard = GenerateShard(keyBase) + } + originalKey := s.buildObjectKey(profile.StoragePath, keyBase, "", shard) + + deleted := 0 + + // Delete the original file + if err := s.s3Client.DeleteObject(ctx, originalKey); err != nil { + return 0, fmt.Errorf("failed to delete original %s: %w", originalKey, err) + } + deleted++ + + // Delete thumbnails if the profile has a thumb_folder + if profile.ThumbFolder != "" { + thumbPrefix := fmt.Sprintf("%s/%s", profile.ThumbFolder, keyBase) + thumbKeys, err := s.s3Client.ListByPrefix(ctx, thumbPrefix) + if err != nil { + // Non-fatal: original is deleted, thumbs may not exist + return deleted, nil + } + for _, key := range thumbKeys { + if err := s.s3Client.DeleteObject(ctx, key); err == nil { + deleted++ + } + } + } + + return deleted, nil +} + // GenerateShard creates a shard from key_base using SHA1 hash func GenerateShard(keyBase string) string { hash := sha1.Sum([]byte(keyBase)) diff --git a/internal/upload/service_test.go b/internal/upload/service_test.go index 2d31a4f..1819c1e 100644 --- a/internal/upload/service_test.go +++ b/internal/upload/service_test.go @@ -54,6 +54,14 @@ func (m *MockS3Client) AbortMultipartUpload(ctx context.Context, key, uploadID s return nil } +func (m *MockS3Client) DeleteObject(ctx context.Context, key string) error { + return nil +} + +func (m *MockS3Client) ListByPrefix(ctx context.Context, prefix string) ([]string, error) { + return nil, nil +} + func TestGenerateShard(t *testing.T) { tests := []struct { keyBase string diff --git a/main.go b/main.go index b564f00..b59f272 100644 --- a/main.go +++ b/main.go @@ -69,6 +69,9 @@ func main() { } }) + // Asset deletion (auth required) + mux.Handle("/v1/assets/", authMiddleware(http.HandlerFunc(uploadHandler.HandleDeleteAsset))) + // Health check mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { response.JSON("OK").Write(w)