feat(panel): 我的提交页上传前按服务端单次上限预检,超限提示显示文件大小与上限

This commit is contained in:
Lemon-miaow committed 2026-09-25 18:37:02 +08:00
1 parent 87dee3e5a5
commit 516c58b543
7 files changed
+82 -5

No files matched your search

@@ -32,7 +32,7 @@
"search_no_results_hint": "Try a different search term or filter.",
"search_placeholder": "Search submissions...",
"error_file_type": "Please upload a valid .tar.gz file.",
"error_file_size": "File exceeds the allowed size limit.",
"error_file_size": "The file is {{size}}; one upload may be at most {{limit}}.",
"error_name_required": "Display name is required.",
"error_file_required": "Build context file is required.",
"field_context_ref": "Context Reference",
@@ -32,7 +32,7 @@
"search_no_results_hint": "尝试更换搜索词或筛选条件。",
"search_placeholder": "搜索提交记录...",
"error_file_type": "请上传有效的 .tar.gz 压缩文件。",
"error_file_size": "文件超过了允许的大小限制。",
"error_file_size": "文件大小为 {{size}},单次上传最多 {{limit}}。",
"error_name_required": "必须填写显示名称。",
"error_file_required": "必须上传构建上下文文件。",
"field_context_ref": "构建上下文引用",
+2
View File
@@ -714,6 +714,8 @@ export const api = rejectingSync({
},
listMySubmissions: (params?: SubmissionListParams) => submissionPage("/me/submissions", params),
/** The per-upload context cap, checked before a file is sent. */
submissionLimits: () => request<{ max_context_bytes: number }>("GET", "/me/submissions/limits"),
createSubmission: (displayName: string) =>
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
+45 -1
View File
@@ -1827,6 +1827,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/me/submissions/limits": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The per-upload build-context cap
* @description The effective [registry] context_max_bytes: 1 GiB by default, 95 MiB behind the Cloudflare edge (its proxy refuses bodies over 100 MB before they reach the API). The panel checks a file against it before upload.
*/
get: operations["submissionLimits"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/submissions/{id}/context": {
parameters: {
query?: never;
@@ -1838,7 +1858,7 @@ export interface paths {
put?: never;
/**
* Upload the modpack build context for your own pending submission (user side; user-directed lane over §16).
* @description The request body IS the raw gzip build context (context.tar.gz) — not JSON, not multipart — streamed to the platform-derived, id-namespaced location Kaniko reads via --context. The submitter is taken from the 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, 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.
* @description The request body IS the raw gzip build context (context.tar.gz) — not JSON, not multipart — streamed to the platform-derived, id-namespaced location Kaniko reads via --context. The submitter is taken from the 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 (the per-upload cap is [registry] context_max_bytes: 1 GiB by default and 95 MiB behind the Cloudflare edge, whose proxy refuses bodies over 100 MB with its own HTML 413 before they reach the API; GET /api/v1/me/submissions/limits reports the effective cap so a client can check a file before sending it), 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.
*/
post: operations["uploadSubmissionContext"];
delete?: never;
@@ -7160,6 +7180,30 @@ export interface operations {
503: components["responses"]["ServiceUnavailable"];
};
};
submissionLimits: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The cap. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/** Format: int64 */
max_context_bytes: number;
};
};
};
503: components["responses"]["ServiceUnavailable"];
};
};
uploadSubmissionContext: {
parameters: {
query?: never;
@@ -9,6 +9,7 @@ import type { SubmissionPage } from "@/lib/types";
const calls = vi.hoisted(() => ({
listMySubmissions: vi.fn(),
submissionLimits: vi.fn(),
}));
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
@@ -34,6 +35,8 @@ const PAGE: SubmissionPage = {
beforeEach(() => {
calls.listMySubmissions.mockReset();
calls.submissionLimits.mockReset();
calls.submissionLimits.mockResolvedValue({ max_context_bytes: 99614720 });
// A filtered page matches fewer rows; the counts still cover everything.
calls.listMySubmissions.mockImplementation(async ({ status }: { status?: string }) =>
status ? { ...PAGE, total: 3 } : PAGE,
@@ -66,4 +69,19 @@ describe("MySubmissionsPage", () => {
expect(screen.getByRole("button", { name: "All Statuses (14)" })).toBeTruthy();
await vi.waitFor(() => expect(screen.queryByText(/^Page \d+ of/)).toBeNull());
});
it("refuses a file over the server's per-upload cap before sending it", async () => {
render(
<MemoryRouter>
<MySubmissionsPage />
</MemoryRouter>,
);
await screen.findByText("Create Above and Beyond");
await vi.waitFor(() => expect(calls.submissionLimits).toHaveBeenCalled());
const pack = new File(["x"], "pack.tar.gz", { type: "application/gzip" });
Object.defineProperty(pack, "size", { value: 125829120 });
await userEvent.click(screen.getByRole("button", { name: "Submit New Modpack" }));
await userEvent.upload(await screen.findByLabelText(/Build Context/), pack);
expect(await screen.findByText("The file is 120 MB; one upload may be at most 95 MB.")).toBeTruthy();
});
});
+10 -2
View File
@@ -44,6 +44,7 @@ import type { BuildStatus, Submission, SubmissionStatus } from "@/lib/types";
const PAGE_SIZE = 10;
const SEARCH_DEBOUNCE_MS = 300;
const DEFAULT_CONTEXT_LIMIT = 1024 * 1024 * 1024;
// The linked build's outcome as shown in a row's expanded details. Colors mirror
// the admin build page; the labels are player-facing, so they come from this
@@ -167,6 +168,12 @@ export function MySubmissionsPage() {
validateAndSetFile(selectedFile);
};
const [contextLimit, setContextLimit] = useState<number | null>(null);
useEffect(() => {
// A failed read keeps the 1 GiB default; the server still refuses past its cap.
api.submissionLimits().then((l) => setContextLimit(l.max_context_bytes), () => undefined);
}, []);
const validateAndSetFile = (selectedFile: File | null) => {
setError(null);
if (!selectedFile) {
@@ -178,9 +185,10 @@ export function MySubmissionsPage() {
setFile(null);
return;
}
const maxBytes = 1024 * 1024 * 1024; // 1 GiB limit
// The server's cap (95 MiB behind the Cloudflare edge); 1 GiB until it answers.
const maxBytes = contextLimit ?? DEFAULT_CONTEXT_LIMIT;
if (selectedFile.size > maxBytes) {
setError(t("error_file_size"));
setError(t("error_file_size", { size: formatBytes(selectedFile.size), limit: formatBytes(maxBytes) }));
setFile(null);
return;
}