Skip to content

Commit ba327d6

Browse files
authored
feat: add support for del op (#9)
1 parent 9740fed commit ba327d6

File tree

6 files changed

+122
-0
lines changed

6 files changed

+122
-0
lines changed

internal/s3/client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,34 @@ func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadID string)
189189
return err
190190
}
191191

192+
// DeleteObject deletes a single object from S3/R2 by key.
193+
func (c *Client) DeleteObject(ctx context.Context, key string) error {
194+
_, err := c.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
195+
Bucket: aws.String(c.bucket),
196+
Key: aws.String(key),
197+
})
198+
return err
199+
}
200+
201+
// ListByPrefix returns all object keys matching the given prefix.
202+
func (c *Client) ListByPrefix(ctx context.Context, prefix string) ([]string, error) {
203+
input := &s3.ListObjectsV2Input{
204+
Bucket: aws.String(c.bucket),
205+
Prefix: aws.String(prefix),
206+
}
207+
208+
result, err := c.s3Client.ListObjectsV2(ctx, input)
209+
if err != nil {
210+
return nil, err
211+
}
212+
213+
keys := make([]string, 0, len(result.Contents))
214+
for _, obj := range result.Contents {
215+
keys = append(keys, *obj.Key)
216+
}
217+
return keys, nil
218+
}
219+
192220
// PartInfo represents a completed part for multipart upload
193221
type PartInfo struct {
194222
ETag string

internal/upload/handlers.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,51 @@ func (h *Handler) HandleAbortMultipart(w http.ResponseWriter, r *http.Request) {
186186
_ = json.NewEncoder(w).Encode(response)
187187
}
188188

189+
// HandleDeleteAsset handles DELETE /v1/assets/{profile}/{key_base}
190+
// Deletes the original file and all generated thumbnails for an asset.
191+
func (h *Handler) HandleDeleteAsset(w http.ResponseWriter, r *http.Request) {
192+
if r.Method != http.MethodDelete {
193+
h.writeError(w, http.StatusMethodNotAllowed, ErrBadRequest, "Method not allowed", "")
194+
return
195+
}
196+
197+
// Extract profile and key_base from URL path
198+
path := strings.TrimPrefix(r.URL.Path, "/v1/assets/")
199+
slashIdx := strings.Index(path, "/")
200+
if slashIdx < 1 || slashIdx == len(path)-1 {
201+
h.writeError(w, http.StatusBadRequest, ErrBadRequest, "Invalid URL format", "Expected /v1/assets/{profile}/{key_base}")
202+
return
203+
}
204+
205+
profileName := path[:slashIdx]
206+
keyBase := path[slashIdx+1:]
207+
208+
// Look up profile config
209+
profile := h.storageConfig.GetProfile(profileName)
210+
if profile == nil {
211+
h.writeError(w, http.StatusBadRequest, ErrBadRequest, fmt.Sprintf("Unknown profile: %s", profileName), "")
212+
return
213+
}
214+
215+
// Delete the original + thumbnails
216+
deleted, err := h.uploadService.DeleteAsset(h.ctx, profile, keyBase)
217+
if err != nil {
218+
fmt.Printf("Delete asset error: %v\n", err)
219+
h.writeError(w, http.StatusInternalServerError, ErrStorageDenied, fmt.Sprintf("Failed to delete asset: %v", err), "")
220+
return
221+
}
222+
223+
w.Header().Set("Content-Type", "application/json")
224+
w.WriteHeader(http.StatusOK)
225+
response := map[string]any{
226+
"status": "deleted",
227+
"profile": profileName,
228+
"key_base": keyBase,
229+
"objects_deleted": deleted,
230+
}
231+
_ = json.NewEncoder(w).Encode(response)
232+
}
233+
189234
// writeError writes a standardized error response
190235
func (h *Handler) writeError(w http.ResponseWriter, statusCode int, code, message, hint string) {
191236
errorResp := ErrorResponse{

internal/upload/interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ type S3Client interface {
1414
PresignUploadPart(ctx context.Context, key, uploadID string, partNumber int32, expires time.Duration) (string, error)
1515
CompleteMultipartUpload(ctx context.Context, key, uploadID string, parts []s3.PartInfo) error
1616
AbortMultipartUpload(ctx context.Context, key, uploadID string) error
17+
DeleteObject(ctx context.Context, key string) error
18+
ListByPrefix(ctx context.Context, prefix string) ([]string, error)
1719
}

internal/upload/service.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,42 @@ func (s *Service) AbortMultipartUpload(ctx context.Context, objectKey, uploadID
236236
return s.s3Client.AbortMultipartUpload(ctx, objectKey, uploadID)
237237
}
238238

239+
// DeleteAsset deletes an asset's original file and all generated thumbnails from R2.
240+
// It resolves the storage paths from the profile config, handling sharding if enabled.
241+
func (s *Service) DeleteAsset(ctx context.Context, profile *config.Profile, keyBase string) (int, error) {
242+
// Build the original object key (same logic as upload)
243+
shard := ""
244+
if profile.EnableSharding {
245+
shard = GenerateShard(keyBase)
246+
}
247+
originalKey := s.buildObjectKey(profile.StoragePath, keyBase, "", shard)
248+
249+
deleted := 0
250+
251+
// Delete the original file
252+
if err := s.s3Client.DeleteObject(ctx, originalKey); err != nil {
253+
return 0, fmt.Errorf("failed to delete original %s: %w", originalKey, err)
254+
}
255+
deleted++
256+
257+
// Delete thumbnails if the profile has a thumb_folder
258+
if profile.ThumbFolder != "" {
259+
thumbPrefix := fmt.Sprintf("%s/%s", profile.ThumbFolder, keyBase)
260+
thumbKeys, err := s.s3Client.ListByPrefix(ctx, thumbPrefix)
261+
if err != nil {
262+
// Non-fatal: original is deleted, thumbs may not exist
263+
return deleted, nil
264+
}
265+
for _, key := range thumbKeys {
266+
if err := s.s3Client.DeleteObject(ctx, key); err == nil {
267+
deleted++
268+
}
269+
}
270+
}
271+
272+
return deleted, nil
273+
}
274+
239275
// GenerateShard creates a shard from key_base using SHA1 hash
240276
func GenerateShard(keyBase string) string {
241277
hash := sha1.Sum([]byte(keyBase))

internal/upload/service_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ func (m *MockS3Client) AbortMultipartUpload(ctx context.Context, key, uploadID s
5454
return nil
5555
}
5656

57+
func (m *MockS3Client) DeleteObject(ctx context.Context, key string) error {
58+
return nil
59+
}
60+
61+
func (m *MockS3Client) ListByPrefix(ctx context.Context, prefix string) ([]string, error) {
62+
return nil, nil
63+
}
64+
5765
func TestGenerateShard(t *testing.T) {
5866
tests := []struct {
5967
keyBase string

main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ func main() {
6969
}
7070
})
7171

72+
// Asset deletion (auth required)
73+
mux.Handle("/v1/assets/", authMiddleware(http.HandlerFunc(uploadHandler.HandleDeleteAsset)))
74+
7275
// Health check
7376
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
7477
response.JSON("OK").Write(w)

0 commit comments

Comments
 (0)