package api import ( "context" "errors" "io" "net/http" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/submit" ) // SubmissionService is the user-modpack approval lane the API depends on (a // user-directed extension over the §16 build subsystem — see the internal/submit // package doc for its provenance). It is an interface so the submission handlers // are unit-tested against a fake; the production implementation is // *submit.Manager. The split mirrors // ImageBuilder: an admin builds an image directly (POST /images/build), whereas // an ordinary user may only SUBMIT a modpack here and an admin must approve it // before anything is built. The approval routes are admin-tier (adminOnly runs // before the handler); the /me/submissions routes are app-tier and scope to the // authenticated principal. // // The identity that matters is never taken from the body: Create stamps // SubmittedBy from the principal and the handler ignores any submitted_by a // client tries to send (decodeJSON rejects it as an unknown field), and Approve/ // Reject record the reviewer from the admin principal's email. type SubmissionService interface { // Create records a pending_review submission. It starts NO build (the whole // point of the lane — nothing is built until an admin approves). Create(ctx context.Context, req submit.CreateRequest) (*submit.Submission, error) // UploadContext stores the modpack blob for the caller's own pending // submission at the platform-derived context ref. submittedBy is the principal, // never the body, so a user can only upload to a submission they own. UploadContext(ctx context.Context, id, submittedBy string, r io.Reader) (*submit.Submission, error) // ListBy returns one user's submissions, newest first (the "my uploads" view). ListBy(ctx context.Context, submittedBy string) ([]submit.Submission, error) // List returns every submission, newest first (the admin review queue). List(ctx context.Context) ([]submit.Submission, error) // Approve is the admin gate: it claims pending_review -> approved (CAS) and the // winner starts the SAME Trivy-gated build as an admin's direct build. Approve(ctx context.Context, id, reviewedBy string) (*submit.Submission, error) // Reject is the admin's other verdict: pending_review -> rejected with a // required reason; it starts no build. Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error) // Withdraw retracts the caller's OWN pending submission: the row and its // uploaded context are deleted. A reviewed submission is frozen (409) and a // submission the caller does not own reads back as 404, like the upload route. Withdraw(ctx context.Context, id, submittedBy string) (*submit.Submission, error) // Delete retires any submission outright (the admin lifecycle valve): the row // and its uploaded context are removed, any status. Delete(ctx context.Context, id string) (*submit.Submission, error) // OpenContext returns the stored build-context blob for the internal // context-fetch route: the build Pod's initContainer cannot mount the uploads // PVC across namespaces and holds no object-store credentials, so it streams // the blob from the API over the service-token-gated internal face instead. OpenContext(ctx context.Context, id string) (io.ReadCloser, error) } // createSubmissionRequest is the POST /me/submissions body. The user // supplies ONLY a human-friendly label; identity comes from the Access principal // and the build inputs (image/context refs) are platform-derived, never from the // body. decodeJSON rejects unknown fields, so a client cannot smuggle a // submitted_by or a context_ref through this endpoint. type createSubmissionRequest struct { DisplayName string `json:"display_name"` } // The submission lane's two cooldown keys, prefixed into the shared submit // limiter's per-user keys. Create and upload are separate levers on purpose: // creating a submission and then immediately uploading its context is the lane's // normal shape, so one must never consume the other's window. const ( submissionCreateKey = "create:" submissionUploadKey = "upload:" ) // rejectSubmissionRequest is the POST /submissions/{id}/reject body. A reason is // required (the submit layer rejects an empty one with 400). type rejectSubmissionRequest struct { Reason string `json:"reason"` } // handleCreateSubmission records a new pending_review submission (app-tier). The // submitter is the authenticated principal's id — never the body — so a user can // only ever file an upload under their own identity. func (a *API) handleCreateSubmission(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) // Reserve the per-user create cooldown BEFORE the store write. Unlike the // wake lever's allowed→record (whose real gate is the running cap and whose // effect is idempotent), a create is a non-idempotent row insertion with no // other bound on its rate, so a burst of truly concurrent creates must yield // exactly one winner per window. The deferred rollback frees the window // whenever the create fails — a 400 typo, a spent quota, a store error — so // only a row that was actually recorded consumes it. lim := a.submitLimiter() reservedAt, ok := lim.reserve(submissionCreateKey+p.UserID, a.SubmitCreateCooldown) if !ok { writeError(w, r, newError(http.StatusTooManyRequests, "submission_cooldown", "a submission was created recently; wait a moment before creating another")) return } committed := false defer func() { if !committed { lim.release(submissionCreateKey+p.UserID, reservedAt) } }() var body createSubmissionRequest if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } sub, err := a.Submissions.Create(r.Context(), submit.CreateRequest{ DisplayName: body.DisplayName, SubmittedBy: p.UserID, }) if err != nil { writeSubmitError(w, r, err) return } committed = true a.audit(r, p.Email, "submission.create", sub.ID) writeJSON(w, http.StatusCreated, sub) } // handleUploadSubmissionContext stores the caller's modpack blob as the build // context for their own pending submission (app-tier). The request body IS the // raw gzip tarball (context.tar.gz) — not JSON, not multipart — streamed straight // to the transport; the submit layer sniffs the gzip magic and caps the size. The // submitter is the authenticated principal, never the body, and a submission the // caller does not own is reported as 404, so this endpoint cannot upload to — or // probe the existence of — another user's submission. // // Uploading does not change the submission row (there is no "uploaded" column): // the blob store is the presence source of truth, which admin approval consults. func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) // Reserve the per-user upload cooldown BEFORE streaming. The body is the // expensive part (up to the 1 GiB blob cap), so without a reservation the // throttle would never bound the resource it exists for: a caller could // repeatedly start long uploads and abort them. Reserving also collapses the // lane's parallel overshoot — a burst of concurrent uploads from one user // yields exactly one admitted stream per replica. The rollback keeps a failed // upload (aborted transfer, wrong format, spent quota) from burning the // window, so a legit retry after a genuine failure is not punished. lim := a.submitLimiter() reservedAt, ok := lim.reserve(submissionUploadKey+p.UserID, a.SubmitUploadCooldown) if !ok { writeError(w, r, newError(http.StatusTooManyRequests, "submission_cooldown", "an upload was accepted recently; wait a moment before uploading again")) return } committed := false defer func() { if !committed { lim.release(submissionUploadKey+p.UserID, reservedAt) } }() id := r.PathValue("id") sub, err := a.Submissions.UploadContext(r.Context(), id, p.UserID, r.Body) if err != nil { writeSubmitError(w, r, err) return } committed = true a.audit(r, p.Email, "submission.upload", sub.ID) writeJSON(w, http.StatusOK, sub) } // handleMySubmissions lists the caller's own submissions (app-tier). It scopes // strictly to the principal's id; there is no parameter that could widen the // query to another user's uploads. Each row is enriched with its linked build's // outcome — this list is the only player-visible outlet for a build result, so // a failed build is not invisible to the person who submitted it. func (a *API) handleMySubmissions(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) subs, err := a.Submissions.ListBy(r.Context(), p.UserID) if err != nil { writeSubmitError(w, r, err) return } views, err := a.submissionViews(r.Context(), subs) if err != nil { writeSubmitError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"submissions": views}) } // handleListSubmissions is the admin review queue: every submission across all // users, newest first (admin-tier — it reads other users' uploads, so it gates // on the admin Zero-Trust path via adminOnly). Rows carry the same build // outcome enrichment as /me/submissions. func (a *API) handleListSubmissions(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } subs, err := a.Submissions.List(r.Context()) if err != nil { writeSubmitError(w, r, err) return } views, err := a.submissionViews(r.Context(), subs) if err != nil { writeSubmitError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"submissions": views}) } // submissionView is one submission row enriched with its linked build's // outcome. The row itself is embedded unchanged, so the wire shape only gains // the two optional fields; they appear solely once a build has been linked // (BuildID set) and its record is still readable. type submissionView struct { submit.Submission BuildStatus string `json:"build_status,omitempty"` BuildError string `json:"build_error,omitempty"` } // submissionViews enriches each submission with its linked build's status via a // read-only Builder.Get — deliberately never Sync, because the 15s reconcile // loop owns state advance and rendering a list must not touch the cluster. A // submission with no linked build (never approved, or approved before the // hand-off could record the id), no Builder wired, or a build row that is gone // (ErrNotFound) renders without the extra fields; any other store failure is // returned so the handler reports it rather than silently dropping the outcome. func (a *API) submissionViews(ctx context.Context, subs []submit.Submission) ([]submissionView, error) { views := make([]submissionView, len(subs)) for i, s := range subs { views[i] = submissionView{Submission: s} if a.Builder == nil || s.BuildID == "" { continue } bld, err := a.Builder.Get(ctx, s.BuildID) if errors.Is(err, build.ErrNotFound) { continue } if err != nil { return nil, err } views[i].BuildStatus = string(bld.Status) views[i].BuildError = bld.Error } return views, nil } // handleApproveSubmission is the admin approve gate (admin-tier). The reviewer is // the admin principal's email; the build inputs are derived inside the submit // layer, so this handler forwards no client-controlled build parameter. func (a *API) handleApproveSubmission(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) id := r.PathValue("id") sub, err := a.Submissions.Approve(r.Context(), id, p.Email) if err != nil { writeSubmitError(w, r, err) return } a.audit(r, p.Email, "submission.approve", sub.ID) writeJSON(w, http.StatusOK, sub) } // handleRejectSubmission records an admin rejection with a required reason // (admin-tier). It starts no build. func (a *API) handleRejectSubmission(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) id := r.PathValue("id") var body rejectSubmissionRequest if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } sub, err := a.Submissions.Reject(r.Context(), id, p.Email, body.Reason) if err != nil { writeSubmitError(w, r, err) return } a.audit(r, p.Email, "submission.reject", sub.ID) writeJSON(w, http.StatusOK, sub) } // handleWithdrawSubmission retracts the caller's own pending submission // (app-tier): the row and its uploaded context are deleted, freeing the pending // slot and the storage budget for a fresh submission. The submitter is the // principal, never the body; a submission the caller does not own is reported as // 404, so this endpoint cannot probe or clear another user's uploads, and a // reviewed submission is 409 (its build may already be consuming the context). func (a *API) handleWithdrawSubmission(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) sub, err := a.Submissions.Withdraw(r.Context(), r.PathValue("id"), p.UserID) if err != nil { writeSubmitError(w, r, err) return } a.audit(r, p.Email, "submission.withdraw", sub.ID) writeJSON(w, http.StatusOK, sub) } // handleDeleteSubmission retires any submission outright (admin-tier): the row // and its uploaded context are removed, any status. This is the lane's lifecycle // valve — the only path that reclaims a rejected or consumed upload from the // uploads PVC. The reviewer identity goes to the audit event, not the (now // nonexistent) row. Deleting an approved submission whose build is still running // fails that build's context fetch; the admin has explicitly chosen to retire it. func (a *API) handleDeleteSubmission(w http.ResponseWriter, r *http.Request) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return } p := principalFromContext(r.Context()) sub, err := a.Submissions.Delete(r.Context(), r.PathValue("id")) if err != nil { writeSubmitError(w, r, err) return } a.audit(r, p.Email, "submission.delete", sub.ID) writeJSON(w, http.StatusOK, sub) } // errSubmissionsUnavailable is returned when the approval lane is not configured // on this api instance (a nil Submissions service), so the admin/app boundary is // still exercised before the subsystem is wired in. var errSubmissionsUnavailable = newError(http.StatusServiceUnavailable, "submissions_unavailable", "modpack submission subsystem is not configured") // writeSubmitError maps submit-package errors onto HTTP status codes. Only the // business sentinels are client-facing: a validation failure is 400, a missing // submission is 404, an already-reviewed submission is 409, a spent per-user // allowance is 403 (the same status the server-resource quota answers with), and // an unconfigured upload transport is 503 (the store this deployment set has no // implemented transport — an honest "not available here", not a client error). Everything // else — including a build.ErrInvalid raised by the pre-CAS build.Validate (a // platform registry/context MISCONFIGURATION, never client input, since every // build input is platform-derived) and a post-CAS Submit hand-off failure — is a // server-side fault that collapses to 500 via writeError. The lane deliberately // does not surface those as 4xx: the client did nothing wrong. func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) { switch { case errors.Is(err, submit.ErrInvalid): writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err.Error())) case errors.Is(err, submit.ErrNotFound): writeError(w, r, newError(http.StatusNotFound, "not_found", "submission not found")) case errors.Is(err, submit.ErrAlreadyReviewed): writeError(w, r, newError(http.StatusConflict, "already_reviewed", "submission has already been reviewed")) case errors.Is(err, submit.ErrQuotaExceeded): writeError(w, r, newError(http.StatusForbidden, "submission_quota_exceeded", "submission quota reached")) case errors.Is(err, submit.ErrBlobNotFound): writeError(w, r, newError(http.StatusNotFound, "not_found", "no context uploaded for this submission")) case errors.Is(err, submit.ErrUploadsUnavailable): writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable", "modpack upload transport is not configured")) default: writeError(w, r, err) } } // handleInternalSubmissionContext streams a submission's stored build-context // tarball to the build Pod's `felis fetch-context` initContainer. It lives on the // internal face (service-token, no Zero Trust) because its only caller is // in-cluster infrastructure: the build Job runs in the build namespace, where it // can neither mount the uploads PVC nor hold object-store credentials, so the API // — which wrote the blob — is the transport. The blob is served verbatim; the // fetcher extracts it under a zip-slip guard, and Kaniko treats the result as // hostile regardless (spec §16). func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) { rc, ok := a.openSubmissionContext(w, r) if !ok { return } streamSubmissionContext(w, rc) } // handleAdminSubmissionContext streams a submission's stored build-context // tarball to a reviewing admin (admin-tier). Review is only a real gate if the // reviewer can inspect what they approve: the executed Dockerfile lives INSIDE // this tarball (build/jobspec.go pins --dockerfile=Dockerfile), so without this // route the human gate could not see the recipe at all. The bytes are the same // ones the build Pod fetches over the internal face; the attachment disposition // makes the browser download the attacker-supplied archive, never render it. func (a *API) handleAdminSubmissionContext(w http.ResponseWriter, r *http.Request) { rc, ok := a.openSubmissionContext(w, r) if !ok { return } w.Header().Set("Content-Disposition", `attachment; filename="context.tar.gz"`) w.Header().Set("X-Content-Type-Options", "nosniff") a.audit(r, principalFromContext(r.Context()).Email, "submission.context.download", r.PathValue("id")) streamSubmissionContext(w, rc) } // openSubmissionContext resolves the build-context blob named in the request // path, mapping the submit-layer errors onto the shared submission statuses (a // missing blob is 404, an unwired transport 503). On failure the error response // is already written and the caller must return. func (a *API) openSubmissionContext(w http.ResponseWriter, r *http.Request) (io.ReadCloser, bool) { if a.Submissions == nil { writeError(w, r, errSubmissionsUnavailable) return nil, false } rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id")) if err != nil { writeSubmitError(w, r, err) return nil, false } return rc, true } // streamSubmissionContext copies the blob to w verbatim and closes it. The // caller must have set every header already: the copy commits the response, so // a failure mid-stream can only truncate it. func streamSubmissionContext(w http.ResponseWriter, rc io.ReadCloser) { defer rc.Close() w.Header().Set("Content-Type", "application/gzip") if _, err := io.Copy(w, rc); err != nil { // The status is already committed; the client sees a truncated stream and // the fetch fails on size/extract, so there is nothing left to write here. return } } // Compile-time proof that the production Manager satisfies the API interface. var _ SubmissionService = (*submit.Manager)(nil)