feat(panel): 我的提交页上传前按服务端单次上限预检,超限提示显示文件大小与上限
This commit is contained in:
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": "构建上下文引用",
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user