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

+21 -2
View File
@@ -4266,6 +4266,15 @@ paths:
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: >-
The per-user submission allowance is spent — too many of the
caller's submissions are awaiting review, or their stored-upload
budget is full (submission_quota_exceeded).
'429':
description: >-
A submission was created within the per-user cooldown window
(submission_cooldown).
'503':
$ref: '#/components/responses/ServiceUnavailable'
get:
@@ -4307,8 +4316,10 @@ paths:
principal; a submission the caller does not own is reported as 404, so
this endpoint cannot upload to or probe another user's submission. Only a
pending_review submission accepts a context (409 otherwise); a wrong-format
or oversize body is rejected with 400. Returns 503 when the deployment's
context store has no implemented upload transport.
or oversize body is rejected with 400, and an upload that would push the
caller past their per-user stored-context budget is refused with 403
before the excess is persisted. Returns 503 when the deployment's context
store has no implemented upload transport.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
@@ -4329,10 +4340,18 @@ paths:
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: >-
The upload would exceed the caller's per-user stored-context budget
(submission_quota_exceeded).
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'429':
description: >-
An upload was accepted within the per-user cooldown window
(submission_cooldown).
'503':
$ref: '#/components/responses/ServiceUnavailable'