Files
Felis/internal/submit/pgstore.go
T
Lemon-miaow ad4d256d8f 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.
2026-09-24 10:14:42 +08:00

168 lines
6.0 KiB
Go

package submit
import (
"context"
"database/sql"
"time"
)
// PGStore is the Postgres-backed Store (image_submissions, migration 0002). It
// is the only submit component that holds database credentials and exposes only
// the narrow operations the Manager needs — no generic UPDATE escape hatch. It
// compiles here but is exercised by integration tests against a live database;
// the Manager's logic is unit-tested against the in-memory fake instead.
type PGStore struct {
db *sql.DB
}
// NewPGStore wraps an open *sql.DB.
func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
// Compile-time proof PGStore satisfies the Store interface.
var _ Store = (*PGStore)(nil)
const submissionColumns = `id, submitted_by, display_name, context_ref, status,
image_ref, build_id, reviewed_by, reject_reason, created_at, reviewed_at`
func (s *PGStore) CreateSubmission(ctx context.Context, sub *Submission) error {
const q = `INSERT INTO image_submissions
(id, submitted_by, display_name, context_ref, status, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`
_, err := s.db.ExecContext(ctx, q,
sub.ID, sub.SubmittedBy, sub.DisplayName, sub.ContextRef, string(sub.Status), sub.CreatedAt)
return err
}
func (s *PGStore) CountPendingSubmissionsBy(ctx context.Context, submittedBy string) (int, error) {
const q = `SELECT count(*) FROM image_submissions
WHERE submitted_by = $1 AND status = 'pending_review'`
var n int
err := s.db.QueryRowContext(ctx, q, submittedBy).Scan(&n)
return n, err
}
func (s *PGStore) GetSubmission(ctx context.Context, id string) (*Submission, error) {
const q = `SELECT ` + submissionColumns + ` FROM image_submissions WHERE id = $1`
return scanSubmission(s.db.QueryRowContext(ctx, q, id))
}
func (s *PGStore) ListSubmissions(ctx context.Context) ([]Submission, error) {
const q = `SELECT ` + submissionColumns + ` FROM image_submissions ORDER BY created_at DESC`
return s.querySubmissions(ctx, q)
}
func (s *PGStore) ListSubmissionsBy(ctx context.Context, submittedBy string) ([]Submission, error) {
const q = `SELECT ` + submissionColumns + `
FROM image_submissions WHERE submitted_by = $1 ORDER BY created_at DESC`
return s.querySubmissions(ctx, q, submittedBy)
}
// cas executes a compare-and-set UPDATE and reports whether THIS call moved the
// row. The `status = 'pending_review'` guard is the actual CAS predicate and is
// deliberately kept INLINE in each caller's query — it is security-visible, so a
// reader auditing "can a non-pending row be flipped?" must see it next to the SET.
// cas only folds the shared ExecContext + RowsAffected tail so the two reviewers
// (approve, reject) cannot drift in how they report a lost race or a RowsAffected
// error. n == 0 means a concurrent review already won the row — reported as
// won=false (never an error), which the Manager maps to ErrAlreadyReviewed.
func (s *PGStore) cas(ctx context.Context, q string, args ...any) (bool, error) {
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
// ApproveSubmission is the approve CAS: it flips the row only while it is still
// pending_review, so a concurrent reviewer cannot also win.
func (s *PGStore) ApproveSubmission(ctx context.Context, id, reviewedBy, imageRef string, at time.Time) (bool, error) {
const q = `UPDATE image_submissions
SET status = 'approved', image_ref = $2, reviewed_by = $3, reviewed_at = $4
WHERE id = $1 AND status = 'pending_review'`
return s.cas(ctx, q, id, imageRef, reviewedBy, at)
}
// RejectSubmission is the reject CAS, mirroring ApproveSubmission.
func (s *PGStore) RejectSubmission(ctx context.Context, id, reviewedBy, reason string, at time.Time) (bool, error) {
const q = `UPDATE image_submissions
SET status = 'rejected', reviewed_by = $2, reject_reason = $3, reviewed_at = $4
WHERE id = $1 AND status = 'pending_review'`
return s.cas(ctx, q, id, reviewedBy, reason, at)
}
func (s *PGStore) LinkBuild(ctx context.Context, id, buildID string) error {
const q = `UPDATE image_submissions SET build_id = $2 WHERE id = $1`
res, err := s.db.ExecContext(ctx, q, id, buildID)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *PGStore) querySubmissions(ctx context.Context, q string, args ...any) ([]Submission, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Submission
for rows.Next() {
sub, err := scanSubmissionRows(rows)
if err != nil {
return nil, err
}
out = append(out, *sub)
}
return out, rows.Err()
}
// rowScanner is the read surface shared by *sql.Row (single) and *sql.Rows (in a
// list loop), so one scan body serves both without copy-paste.
type rowScanner interface {
Scan(dest ...any) error
}
// scanSubmission scans a single row, translating no-rows into ErrNotFound.
func scanSubmission(row *sql.Row) (*Submission, error) {
sub, err := scanSubmissionRows(row)
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
return sub, err
}
// scanSubmissionRows decodes one row's columns, mapping nullable text/time
// columns through sql.Null* (NULLIF/absent values become zero, omitted in JSON).
func scanSubmissionRows(row rowScanner) (*Submission, error) {
var (
sub Submission
status string
imageRef, buildID, reviewedBy, rejectReason sql.NullString
reviewedAt sql.NullTime
)
if err := row.Scan(
&sub.ID, &sub.SubmittedBy, &sub.DisplayName, &sub.ContextRef, &status,
&imageRef, &buildID, &reviewedBy, &rejectReason, &sub.CreatedAt, &reviewedAt,
); err != nil {
return nil, err
}
sub.Status = Status(status)
sub.ImageRef = imageRef.String
sub.BuildID = buildID.String
sub.ReviewedBy = reviewedBy.String
sub.RejectReason = rejectReason.String
if reviewedAt.Valid {
t := reviewedAt.Time
sub.ReviewedAt = &t
}
return &sub, nil
}