Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions internal/s3/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/upload/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 2 additions & 0 deletions internal/upload/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
36 changes: 36 additions & 0 deletions internal/upload/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
8 changes: 8 additions & 0 deletions internal/upload/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading