feat(submit): 模组上传改为分片续传并显示进度,经 Cloudflare 边缘也能传满 1GiB
This commit is contained in:
28 files changed
+2543
-154
No files matched your search
@@ -616,6 +616,13 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// App-tier and owner-scoped (the id must belong to the principal), exactly
|
||||
// like the create/list routes above.
|
||||
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
|
||||
// The chunked form of that upload, for a context larger than one request
|
||||
// carries through the edge (Cloudflare refuses bodies over 100 MB): GET
|
||||
// reports the staged length (the resume point), PUT ?offset= appends one
|
||||
// part, POST .../complete stores the staged whole. Same owner scoping.
|
||||
{Method: "GET", Pattern: "/api/v1/me/submissions/{id}/context/upload", h: a.handleContextUploadStatus},
|
||||
{Method: "PUT", Pattern: "/api/v1/me/submissions/{id}/context/upload", h: a.handleContextUploadPart},
|
||||
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context/upload/complete", h: a.handleContextUploadComplete},
|
||||
// Withdraw the caller's OWN pending submission: the row and its uploaded
|
||||
// context are deleted, freeing the pending slot and storage budget. Same
|
||||
// owner-scoping as the upload route — a reviewed submission is frozen (409)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"felis.lolicon.best/internal/build"
|
||||
"felis.lolicon.best/internal/submit"
|
||||
"felis.lolicon.best/internal/updates"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
@@ -34,26 +35,27 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
|
||||
}
|
||||
|
||||
pairs := map[string]any{
|
||||
"ServerInfo": ServerInfo{},
|
||||
"FleetServer": fleetServerView{},
|
||||
"MyServerView": MyServerView{},
|
||||
"BackupView": BackupView{},
|
||||
"Build": build.Build{},
|
||||
"Image": build.Image{},
|
||||
"BuildScan": buildScanView{},
|
||||
"ScanSummary": build.ScanSummary{},
|
||||
"ScanPolicy": build.ScanPolicy{},
|
||||
"ScanFinding": build.ScanFinding{},
|
||||
"Submission": submissionView{},
|
||||
"UserView": UserView{},
|
||||
"UserDetail": UserDetail{},
|
||||
"QuotaView": QuotaView{},
|
||||
"SessionView": SessionView{},
|
||||
"PasskeyCredential": passkeyCredentialView{},
|
||||
"UpdateWindow": updateWindow{},
|
||||
"DBBackupStatus": dbBackupView{},
|
||||
"UpdateReport": updateReportView{},
|
||||
"UpdateComponent": updates.ComponentStatus{},
|
||||
"ServerInfo": ServerInfo{},
|
||||
"FleetServer": fleetServerView{},
|
||||
"MyServerView": MyServerView{},
|
||||
"BackupView": BackupView{},
|
||||
"Build": build.Build{},
|
||||
"Image": build.Image{},
|
||||
"BuildScan": buildScanView{},
|
||||
"ScanSummary": build.ScanSummary{},
|
||||
"ScanPolicy": build.ScanPolicy{},
|
||||
"ScanFinding": build.ScanFinding{},
|
||||
"Submission": submissionView{},
|
||||
"ContextUploadProgress": submit.UploadProgress{},
|
||||
"UserView": UserView{},
|
||||
"UserDetail": UserDetail{},
|
||||
"QuotaView": QuotaView{},
|
||||
"SessionView": SessionView{},
|
||||
"PasskeyCredential": passkeyCredentialView{},
|
||||
"UpdateWindow": updateWindow{},
|
||||
"DBBackupStatus": dbBackupView{},
|
||||
"UpdateReport": updateReportView{},
|
||||
"UpdateComponent": updates.ComponentStatus{},
|
||||
}
|
||||
for name, v := range pairs {
|
||||
s, ok := doc.Components.Schemas[name]
|
||||
|
||||
+108
-11
@@ -36,6 +36,14 @@ type SubmissionService interface {
|
||||
// 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)
|
||||
// UploadStatus, UploadPart and CompleteUpload are the chunked form of
|
||||
// UploadContext, for a context larger than one request carries through the
|
||||
// edge (Cloudflare refuses bodies over 100 MB): parts are staged in order, the
|
||||
// staged length is the resume point, and completion stores the whole through
|
||||
// UploadContext's checks. Same owner scoping.
|
||||
UploadStatus(ctx context.Context, id, submittedBy string) (submit.UploadProgress, error)
|
||||
UploadPart(ctx context.Context, id, submittedBy string, offset int64, r io.Reader) (submit.UploadProgress, error)
|
||||
CompleteUpload(ctx context.Context, id, submittedBy string) (*submit.Submission, error)
|
||||
// ListBy returns one page of one user's submissions, newest first (the "my
|
||||
// uploads" view). The scope is submittedBy, whatever opts says.
|
||||
ListBy(ctx context.Context, submittedBy string, opts submit.ListOpts) (submit.Page, error)
|
||||
@@ -173,26 +181,105 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
|
||||
// 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)
|
||||
commit, release, ok := a.reserveUpload(w, r, p.UserID)
|
||||
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)
|
||||
}
|
||||
}()
|
||||
defer release()
|
||||
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
|
||||
commit()
|
||||
a.audit(r, "submission.upload", sub.ID)
|
||||
writeJSON(w, http.StatusOK, sub)
|
||||
}
|
||||
|
||||
// reserveUpload claims the per-user upload cooldown for userID, answering 429
|
||||
// when an upload landed within it. commit keeps the reservation; release, run
|
||||
// deferred, gives it back unless commit ran.
|
||||
func (a *API) reserveUpload(w http.ResponseWriter, r *http.Request, userID string) (commit, release func(), ok bool) {
|
||||
lim := a.submitLimiter()
|
||||
reservedAt, ok := lim.reserve(submissionUploadKey+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 nil, nil, false
|
||||
}
|
||||
committed := false
|
||||
return func() { committed = true }, func() {
|
||||
if !committed {
|
||||
lim.release(submissionUploadKey+userID, reservedAt)
|
||||
}
|
||||
}, true
|
||||
}
|
||||
|
||||
// handleContextUploadStatus reports how far the caller's chunked upload of a
|
||||
// submission has come (app-tier, owner-scoped like the single upload). The
|
||||
// panel reads it before the first part and again after a failed one, and sends
|
||||
// the next part from received.
|
||||
func (a *API) handleContextUploadStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
prog, err := a.Submissions.UploadStatus(r.Context(), r.PathValue("id"), p.UserID)
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, prog)
|
||||
}
|
||||
|
||||
// handleContextUploadPart appends one part of the caller's chunked upload. The
|
||||
// body is the raw bytes; ?offset= is where they start: 0 starts over, anything
|
||||
// else must equal the staged length (409 upload_offset_mismatch otherwise). A
|
||||
// part is small enough for any edge, so no cooldown applies here: the staged
|
||||
// total is bounded by the context cap and the storage budget, and completion
|
||||
// holds the cooldown.
|
||||
func (a *API) handleContextUploadPart(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
}
|
||||
offset, err := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
"offset must be the byte position the part starts at"))
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
prog, err := a.Submissions.UploadPart(r.Context(), r.PathValue("id"), p.UserID, offset, r.Body)
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, prog)
|
||||
}
|
||||
|
||||
// handleContextUploadComplete stores the caller's staged upload as the
|
||||
// submission's context. It holds the per-user upload cooldown and is audited
|
||||
// like the single upload, since this is where a context lands.
|
||||
func (a *API) handleContextUploadComplete(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Submissions == nil {
|
||||
writeError(w, r, errSubmissionsUnavailable)
|
||||
return
|
||||
}
|
||||
p := principalFromContext(r.Context())
|
||||
commit, release, ok := a.reserveUpload(w, r, p.UserID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
sub, err := a.Submissions.CompleteUpload(r.Context(), r.PathValue("id"), p.UserID)
|
||||
if err != nil {
|
||||
writeSubmitError(w, r, err)
|
||||
return
|
||||
}
|
||||
commit()
|
||||
a.audit(r, "submission.upload", sub.ID)
|
||||
writeJSON(w, http.StatusOK, sub)
|
||||
}
|
||||
@@ -431,6 +518,7 @@ var errSubmissionsUnavailable = newError(http.StatusServiceUnavailable, "submiss
|
||||
// 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) {
|
||||
var mismatch *submit.OffsetMismatchError
|
||||
switch {
|
||||
case errors.Is(err, submit.ErrInvalid):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s", err.Error()))
|
||||
@@ -453,6 +541,15 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
case errors.Is(err, submit.ErrUploadsUnavailable):
|
||||
writeError(w, r, newError(http.StatusServiceUnavailable, "uploads_unavailable",
|
||||
"modpack upload transport is not configured"))
|
||||
case errors.Is(err, submit.ErrUploadBusy):
|
||||
writeError(w, r, newError(http.StatusConflict, "upload_busy",
|
||||
"another request is still writing this upload; read where it stands and continue from there"))
|
||||
case errors.As(err, &mismatch):
|
||||
writeError(w, r, newError(http.StatusConflict, "upload_offset_mismatch",
|
||||
"the upload holds %d bytes; send the part that starts there", mismatch.Received))
|
||||
case errors.Is(err, submit.ErrPartTooLarge):
|
||||
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "part_too_large",
|
||||
"the part is larger than part_max_bytes in the upload status"))
|
||||
default:
|
||||
writeError(w, r, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"felis.lolicon.best/internal/submit"
|
||||
)
|
||||
|
||||
func TestContextUploadPartForwardsOffsetBodyAndPrincipal(t *testing.T) {
|
||||
fs := &fakeSubmissions{progress: submit.UploadProgress{Received: 8, PartMaxBytes: 33554432, MaxContextBytes: 1073741824}}
|
||||
api := appSubAPI(fs)
|
||||
w := do(api.ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=4", "abcd",
|
||||
ctHeader("application/octet-stream"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != `{"received":8,"part_max_bytes":33554432,"max_context_bytes":1073741824}`+"\n" {
|
||||
t.Fatalf("body = %s", got)
|
||||
}
|
||||
if fs.chunkID != "sub-9" || fs.chunkBy != "user-7" || fs.partOffset != 4 || fs.partBody != "abcd" {
|
||||
t.Fatalf("forwarded id=%q by=%q offset=%d body=%q, want sub-9 user-7 4 abcd", fs.chunkID, fs.chunkBy, fs.partOffset, fs.partBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUploadPartNeedsAnOffset(t *testing.T) {
|
||||
for _, target := range []string{
|
||||
"/api/v1/me/submissions/sub-9/context/upload",
|
||||
"/api/v1/me/submissions/sub-9/context/upload?offset=four",
|
||||
} {
|
||||
fs := &fakeSubmissions{}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "PUT", target, "abcd", nil)
|
||||
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
|
||||
t.Fatalf("%s: code = %d (%s), want 400 bad_request", target, w.Code, w.Body.String())
|
||||
}
|
||||
if fs.chunkID != "" {
|
||||
t.Fatalf("%s: the part reached the lane", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUploadStatusReportsTheStagedLength(t *testing.T) {
|
||||
fs := &fakeSubmissions{progress: submit.UploadProgress{Received: 50331648, PartMaxBytes: 33554432, MaxContextBytes: 1073741824}}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "GET", "/api/v1/me/submissions/sub-9/context/upload", "", nil)
|
||||
if w.Code != http.StatusOK || w.Body.String() != `{"received":50331648,"part_max_bytes":33554432,"max_context_bytes":1073741824}`+"\n" {
|
||||
t.Fatalf("status = %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if fs.chunkID != "sub-9" || fs.chunkBy != "user-7" {
|
||||
t.Fatalf("asked for id=%q by=%q, want sub-9 user-7", fs.chunkID, fs.chunkBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUploadErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
code int
|
||||
want string
|
||||
msg string
|
||||
}{
|
||||
{"offset mismatch", &submit.OffsetMismatchError{Received: 12}, 409, "upload_offset_mismatch", "the upload holds 12 bytes"},
|
||||
{"busy", submit.ErrUploadBusy, 409, "upload_busy", ""},
|
||||
{"part too large", fmt.Errorf("submit: write upload part: %w", submit.ErrPartTooLarge), 413, "part_too_large", ""},
|
||||
{"not owned", submit.ErrNotFound, 404, "not_found", ""},
|
||||
{"no part store", submit.ErrUploadsUnavailable, 503, "uploads_unavailable", ""},
|
||||
} {
|
||||
fs := &fakeSubmissions{chunkErr: tc.err}
|
||||
w := do(appSubAPI(fs).ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=12", "abcd", nil)
|
||||
if w.Code != tc.code || decodeErr(t, w) != tc.want {
|
||||
t.Errorf("%s: %d %s, want %d %s", tc.name, w.Code, w.Body.String(), tc.code, tc.want)
|
||||
}
|
||||
if tc.msg != "" && !strings.Contains(w.Body.String(), tc.msg) {
|
||||
t.Errorf("%s: body %s does not say %q", tc.name, w.Body.String(), tc.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion is where a context lands: it holds the per-user upload cooldown and
|
||||
// writes the same audit event as the single upload, and a failed completion
|
||||
// gives the cooldown back.
|
||||
func TestContextUploadCompleteHoldsTheCooldownAndAudits(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
fs := &fakeSubmissions{}
|
||||
api := appSubAPI(fs)
|
||||
api.Repo = repo
|
||||
api.SubmitUploadCooldown = time.Minute
|
||||
eh := api.ExternalHandler()
|
||||
|
||||
fs.completeErr = submit.ErrUploadsUnavailable
|
||||
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context/upload/complete", "", nil); w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("failed completion: code = %d, want 503 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
fs.completeErr = nil
|
||||
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context/upload/complete", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("completion right after a failed one: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if fs.chunkID != "sub-9" || fs.chunkBy != "user-7" {
|
||||
t.Fatalf("completed id=%q by=%q, want sub-9 user-7", fs.chunkID, fs.chunkBy)
|
||||
}
|
||||
var audited int
|
||||
for _, e := range repo.audits {
|
||||
if e.Action == "submission.upload" {
|
||||
audited++
|
||||
}
|
||||
}
|
||||
if audited != 1 {
|
||||
t.Fatalf("submission.upload audits = %d, want 1 (only the completion that stored): %+v", audited, repo.audits)
|
||||
}
|
||||
w = do(eh, "POST", "/api/v1/me/submissions/sub-9/context/upload/complete", "", nil)
|
||||
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "submission_cooldown" {
|
||||
t.Fatalf("second completion in the window: %d %s, want 429 submission_cooldown", w.Code, w.Body.String())
|
||||
}
|
||||
// A part never waits on the cooldown.
|
||||
if w := do(eh, "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0", "\x1f\x8b", nil); w.Code != http.StatusOK {
|
||||
t.Fatalf("part inside the cooldown: code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUploadWithoutServiceIs503(t *testing.T) {
|
||||
api := appSubAPI(&fakeSubmissions{})
|
||||
api.Submissions = nil
|
||||
eh := api.ExternalHandler()
|
||||
for _, rq := range [][2]string{
|
||||
{"GET", "/api/v1/me/submissions/sub-9/context/upload"},
|
||||
{"PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0"},
|
||||
{"POST", "/api/v1/me/submissions/sub-9/context/upload/complete"},
|
||||
} {
|
||||
if w := do(eh, rq[0], rq[1], "", nil); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s %s = %d, want 503", rq[0], rq[1], w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,35 @@ type fakeSubmissions struct {
|
||||
|
||||
approvedDigest string
|
||||
openDigest string
|
||||
|
||||
// The chunked upload: what the handler forwarded, and canned outcomes.
|
||||
chunkID string
|
||||
chunkBy string
|
||||
partOffset int64
|
||||
partBody string
|
||||
progress submit.UploadProgress
|
||||
chunkErr error
|
||||
completeErr error
|
||||
}
|
||||
|
||||
func (f *fakeSubmissions) UploadStatus(_ context.Context, id, submittedBy string) (submit.UploadProgress, error) {
|
||||
f.chunkID, f.chunkBy = id, submittedBy
|
||||
return f.progress, f.chunkErr
|
||||
}
|
||||
|
||||
func (f *fakeSubmissions) UploadPart(_ context.Context, id, submittedBy string, offset int64, r io.Reader) (submit.UploadProgress, error) {
|
||||
f.chunkID, f.chunkBy, f.partOffset = id, submittedBy, offset
|
||||
b, _ := io.ReadAll(r)
|
||||
f.partBody = string(b)
|
||||
return f.progress, f.chunkErr
|
||||
}
|
||||
|
||||
func (f *fakeSubmissions) CompleteUpload(_ context.Context, id, submittedBy string) (*submit.Submission, error) {
|
||||
f.chunkID, f.chunkBy = id, submittedBy
|
||||
if f.completeErr != nil {
|
||||
return nil, f.completeErr
|
||||
}
|
||||
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
|
||||
}
|
||||
|
||||
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
|
||||
|
||||
Reference in new issue
Block a user