Skip to content

Commit eafdd13

Browse files
committed
feat: cap image uploads (5MB + 30/hour per creator); document no-expiry retention
- Lower the size cap 15MB → 5MB (server + client pre-check). - Per-creator Redis rate limit (30/hour) on POST /api/v1/uploads → 429 over cap; fails open on a Redis error so a blip never blocks a legit upload. - Retention: deliberately NO S3 expiry (would break images on older carts); control growth at intake. Runbook updated with the decision + optional multipart-abort hygiene rule + note that the role has PutObject only.
1 parent 0fd566f commit eafdd13

6 files changed

Lines changed: 89 additions & 10 deletions

File tree

cmd/shoplit-api/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ func run() error {
143143
r.Use(sm.RequireUser())
144144
r.Post("/extension/token", exttoken.MintHandler(q))
145145
r.Get("/feedback", fb.ListHandler())
146-
r.Post("/uploads", uploads.Handler(imageStore))
146+
r.Post("/uploads", uploads.Handler(imageStore, uploads.NewRedisLimiter(rc, 30, time.Hour)))
147147
carts.RegisterRoutes(r, svc, fetcher)
148148
})
149149

docs/superpowers/runbooks/s3-image-uploads-setup.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ When `SHOPLIT_S3_BUCKET` is set, the upload handler uses S3; otherwise it falls
6161
to local disk (dev). URLs returned look like
6262
`https://shoplit-uploads-prod.s3.ap-southeast-2.amazonaws.com/<random>.jpg`.
6363

64+
## Retention & upload limits (decided 2026-05-25)
65+
- **No expiry lifecycle.** Product/cover images must live as long as their cart,
66+
so we deliberately do NOT auto-delete by age (a flat 30-day rule would break
67+
images on older carts). Storage growth is controlled at intake instead.
68+
- **Upload limits (enforced server-side in `internal/uploads`):**
69+
- **5 MB** max per image (`maxUploadBytes`), JPEG/PNG/WebP/GIF only.
70+
- **30 uploads/hour per creator** (Redis fixed-window, `NewRedisLimiter`);
71+
over the cap → HTTP 429. The web picker also pre-checks 5 MB client-side.
72+
- **Optional S3 hygiene rule** (no data loss, safe to add in the console):
73+
Bucket → Management → Lifecycle rule → "Delete incomplete multipart uploads
74+
after 7 days". Our uploads are single-part PutObject, so this is effectively a
75+
no-op today — add it only as a best-practice belt.
76+
- The EC2 instance role has **`s3:PutObject` only** (no `DeleteObject`/lifecycle),
77+
so neither the app nor a stray script can delete bucket objects.
78+
6479
## 5. (Optional) verify the role works, from the EC2 box
6580
```
6681
aws sts get-caller-identity # shows the assumed role

internal/uploads/ratelimit.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package uploads
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
goredis "github.com/redis/go-redis/v9"
9+
)
10+
11+
// Limiter decides whether a user may upload again right now.
12+
type Limiter interface {
13+
Allow(ctx context.Context, userID int64) (bool, error)
14+
}
15+
16+
// RedisLimiter is a fixed-window per-user counter: at most `max` uploads per
17+
// `window`. Survives API restarts (unlike an in-memory map) and is shared if
18+
// the API ever runs more than one instance.
19+
type RedisLimiter struct {
20+
rc *goredis.Client
21+
max int
22+
window time.Duration
23+
}
24+
25+
func NewRedisLimiter(rc *goredis.Client, max int, window time.Duration) *RedisLimiter {
26+
return &RedisLimiter{rc: rc, max: max, window: window}
27+
}
28+
29+
func (l *RedisLimiter) Allow(ctx context.Context, userID int64) (bool, error) {
30+
key := fmt.Sprintf("upload_rate:%d", userID)
31+
n, err := l.rc.Incr(ctx, key).Result()
32+
if err != nil {
33+
// Fail open: a Redis hiccup shouldn't block creators from uploading.
34+
return true, err
35+
}
36+
if n == 1 {
37+
// First hit in this window — start the TTL.
38+
_ = l.rc.Expire(ctx, key, l.window).Err()
39+
}
40+
return n <= int64(l.max), nil
41+
}

internal/uploads/uploads.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import (
88
"encoding/hex"
99
"encoding/json"
1010
"io"
11+
"log/slog"
1112
"net/http"
1213
"os"
14+
15+
"github.com/mayur-tolexo/shoplit/internal/auth"
1316
)
1417

1518
const (
16-
maxUploadBytes = 15 << 20 // 15 MiB — generous for a full-res phone photo.
17-
sniffLen = 512 // bytes http.DetectContentType needs.
19+
maxUploadBytes = 5 << 20 // 5 MiB cap per image.
20+
sniffLen = 512 // bytes http.DetectContentType needs.
1821
)
1922

2023
// extByType maps the sniffed content type to a safe, server-chosen extension.
@@ -29,13 +32,27 @@ var extByType = map[string]string{
2932

3033
// Handler returns an http.HandlerFunc that accepts a multipart "file" field,
3134
// validates it is an allowed image, hands it to the Store with a random name,
32-
// and responds {"url":"<public url>"}. Mount it behind auth.
33-
func Handler(store Store) http.HandlerFunc {
35+
// and responds {"url":"<public url>"}. Mount it behind auth. When limiter is
36+
// non-nil, each creator is capped to its per-window allowance.
37+
func Handler(store Store, limiter Limiter) http.HandlerFunc {
3438
return func(w http.ResponseWriter, r *http.Request) {
39+
// Per-creator rate limit (anti-abuse / S3 cost control). Fail open if
40+
// the limiter errors so a Redis blip never blocks a legit upload.
41+
if limiter != nil {
42+
uid, _ := auth.UserIDFromContext(r.Context())
43+
allowed, err := limiter.Allow(r.Context(), uid)
44+
if err != nil {
45+
slog.Warn("upload rate limiter error; allowing", "uid", uid, "err", err)
46+
} else if !allowed {
47+
http.Error(w, "upload limit reached — please try again later", http.StatusTooManyRequests)
48+
return
49+
}
50+
}
51+
3552
// Cap the whole request body before reading anything.
3653
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1024)
3754
if err := r.ParseMultipartForm(maxUploadBytes + 1024); err != nil {
38-
http.Error(w, "file too large or malformed (max 15MB)", http.StatusRequestEntityTooLarge)
55+
http.Error(w, "file too large or malformed (max 5MB)", http.StatusRequestEntityTooLarge)
3956
return
4057
}
4158
file, _, err := r.FormFile("file")
@@ -51,7 +68,7 @@ func Handler(store Store) http.HandlerFunc {
5168
return
5269
}
5370
if len(data) > maxUploadBytes {
54-
http.Error(w, "image too large (max 15MB)", http.StatusRequestEntityTooLarge)
71+
http.Error(w, "image too large (max 5MB)", http.StatusRequestEntityTooLarge)
5572
return
5673
}
5774

internal/uploads/uploads_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestUpload_StoresImageAndReturnsURL(t *testing.T) {
4646
req.Header.Set("Content-Type", ct)
4747
rr := httptest.NewRecorder()
4848

49-
uploads.Handler(uploads.NewDiskStore(dir)).ServeHTTP(rr, req)
49+
uploads.Handler(uploads.NewDiskStore(dir), nil).ServeHTTP(rr, req)
5050

5151
if rr.Code != http.StatusOK {
5252
t.Fatalf("want 200, got %d (%s)", rr.Code, rr.Body.String())
@@ -72,7 +72,7 @@ func TestUpload_RejectsNonImage(t *testing.T) {
7272
req.Header.Set("Content-Type", ct)
7373
rr := httptest.NewRecorder()
7474

75-
uploads.Handler(uploads.NewDiskStore(dir)).ServeHTTP(rr, req)
75+
uploads.Handler(uploads.NewDiskStore(dir), nil).ServeHTTP(rr, req)
7676

7777
if rr.Code != http.StatusUnsupportedMediaType {
7878
t.Fatalf("want 415 for non-image, got %d", rr.Code)
@@ -88,7 +88,7 @@ func TestUpload_MissingFile(t *testing.T) {
8888
req.Header.Set("Content-Type", w.FormDataContentType())
8989
rr := httptest.NewRecorder()
9090

91-
uploads.Handler(uploads.NewDiskStore(dir)).ServeHTTP(rr, req)
91+
uploads.Handler(uploads.NewDiskStore(dir), nil).ServeHTTP(rr, req)
9292

9393
if rr.Code != http.StatusBadRequest {
9494
t.Fatalf("want 400 for missing file, got %d", rr.Code)

web/components/image-upload-button.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ export function ImageUploadButton({
1818
}) {
1919
const [uploading, setUploading] = useState(false);
2020

21+
const MAX_BYTES = 5 * 1024 * 1024; // mirrors the server's 5MB cap
22+
2123
const onPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
2224
const file = e.target.files?.[0];
2325
e.target.value = ""; // allow re-picking the same file
2426
if (!file) return;
27+
if (file.size > MAX_BYTES) {
28+
toast.error("That photo is over 5MB — pick a smaller one.");
29+
return;
30+
}
2531
setUploading(true);
2632
try {
2733
onUploaded(await uploadImage(file));

0 commit comments

Comments
 (0)