fix(submit): bound the untrusted upload lane — per-user caps + throttles (#75)

A logged-in user could file submissions without bound and stream a 1 GiB
context per submission. The only limits were the single-blob size cap and the
5 GiB uploads PVC (platform/workloads.go); nothing counted a user's rows or
bytes, so one account could fill the volume and every other user's upload
would start failing.

- Create: per-user pending_review cap (default 5) — the review queue cannot
  be parked full of one account's rows. Check-then-insert, documented soft.
- UploadContext: per-user stored-context budget (default 2 GiB) charged
  against the blob store's REAL sizes (new Blobs.Size on local/S3 stores), so
  the sum cannot drift from the volume; the write is capped at the remaining
  budget, so the excess is refused before it is persisted, and a re-upload is
  charged only for its new bytes.
- API: per-user create/upload throttles (30s/15s, cmd/felis-wired) on a
  dedicated cooldown keyspace, reserve→release so a failed attempt never
  burns the window and a burst collapses to one winner; ErrQuotaExceeded →
  403 submission_quota_exceeded (distinct from the 400 an oversize blob
  gets), 429 submission_cooldown for the throttles.
- Panel: zh/en copy for both codes; openapi documents 403/429 on the two
  user routes; pgint covers the pending-queue count.

Unit tests: submit package (cap, budget boundary/exact-fit/replacement,
oversize-vs-quota split) and api handlers (quota 403 both paths, throttle
429 + recovery + failure-release). go vet/go test/gofmt clean; panel
vitest 118 + typecheck green.
This commit is contained in:
Lemon-miaow committed 2026-09-24 10:14:42 +08:00
1 parent 3ed8bd7be9
commit ad4d256d8f
15 files changed
+577 -15

No files matched your search

+12
View File
@@ -980,6 +980,14 @@ func TestSubmitStoreContract(t *testing.T) {
if err := s.CreateSubmission(ctx, sub); err != nil {
t.Fatalf("CreateSubmission: %v", err)
}
// The pending-queue count is the read behind the per-user pending cap: a
// fresh user starts at zero and a pending row counts.
if n, err := s.CountPendingSubmissionsBy(ctx, u.ID); err != nil || n != 1 {
t.Fatalf("CountPendingSubmissionsBy after create = (%d, %v), want (1, nil)", n, err)
}
if n, err := s.CountPendingSubmissionsBy(ctx, u.ID+"-nobody"); err != nil || n != 0 {
t.Fatalf("CountPendingSubmissionsBy for an unknown user = (%d, %v), want (0, nil)", n, err)
}
got, err := s.GetSubmission(ctx, id)
if err != nil {
t.Fatalf("GetSubmission: %v", err)
@@ -1012,6 +1020,10 @@ func TestSubmitStoreContract(t *testing.T) {
if ok, err := s.RejectSubmission(ctx, id, "[email protected]", "no", now); err != nil || ok {
t.Fatalf("reject after approve = (%v, %v), want (false, nil)", ok, err)
}
// A reviewed row leaves the pending queue.
if n, err := s.CountPendingSubmissionsBy(ctx, u.ID); err != nil || n != 0 {
t.Fatalf("CountPendingSubmissionsBy after approve = (%d, %v), want (0, nil)", n, err)
}
got, _ = s.GetSubmission(ctx, id)
if got.Status != submit.StatusApproved || got.ImageRef == "" || got.ReviewedBy != "[email protected]" || got.ReviewedAt == nil {
t.Fatalf("approved row = %+v", got)