fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束
This commit is contained in:
59 files changed
+2538
-338
No files matched your search
@@ -25,6 +25,12 @@ export function isManaged(path: string): boolean {
|
||||
/** The longest name a Linux filesystem takes, in bytes (NAME_MAX). */
|
||||
const NAME_MAX = 255;
|
||||
|
||||
/** nameTooLong says whether a name is past NAME_MAX. A Mac or Windows disk
|
||||
* counts in characters, so a file picked there can be. */
|
||||
export function nameTooLong(name: string): boolean {
|
||||
return new TextEncoder().encode(name).length > NAME_MAX;
|
||||
}
|
||||
|
||||
export type NameProblem = "name_required" | "name_slash" | "name_dots" | "name_too_long" | "name_taken";
|
||||
|
||||
/** nameProblem says why name cannot be used for a new entry in a folder listing
|
||||
@@ -40,7 +46,7 @@ export function nameProblem(
|
||||
if (name === "") return "name_required";
|
||||
if (name.includes("/")) return "name_slash";
|
||||
if (name === "." || name === "..") return "name_dots";
|
||||
if (new TextEncoder().encode(name).length > NAME_MAX) return "name_too_long";
|
||||
if (nameTooLong(name)) return "name_too_long";
|
||||
if (name !== current && entries?.some((e) => e.name === name)) return "name_taken";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ describe("opErrorText", () => {
|
||||
expect(opErrorText("unzip", e("volume_full", { need: 3 * 1024 * 1024, avail: 1024 }))).toBe(
|
||||
"Not enough room on the world volume: 3.0 MiB needed, 1.0 KiB free. Nothing was changed.",
|
||||
);
|
||||
// A full volume: the Job leaves the zero out.
|
||||
expect(opErrorText("upload", e("volume_full", { need: 2048 }))).toBe(
|
||||
"Not enough room on the world volume: 2.0 KiB needed, 0 B free. Nothing was changed.",
|
||||
);
|
||||
expect(opErrorText("upload", e("volume_full"))).toBe(humanizeError({ status: 0, code: "volume_full", message: "raw words" }));
|
||||
});
|
||||
|
||||
@@ -50,6 +54,17 @@ describe("opErrorText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("tells an extraction killed for memory to split the archive", () => {
|
||||
// The message felis-api words a memory kill with.
|
||||
const oom = "the file operation ran out of memory (OOMKilled); an archive of this many files has to be split into smaller ones";
|
||||
expect(opErrorText("unzip", e("job_failed", { message: oom }))).toBe(
|
||||
"The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one.",
|
||||
);
|
||||
expect(opErrorText("upload", e("job_failed", { message: oom }))).toBe(
|
||||
"The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("words any other code as the file routes do", () => {
|
||||
expect(opErrorText("upload", e("file_changed"))).toBe(humanizeError({ status: 0, code: "file_changed", message: "raw words" }));
|
||||
expect(opErrorText("upload", e("file_changed"))).not.toBe("raw words");
|
||||
|
||||
@@ -14,9 +14,11 @@ export function opErrorText(op: FileOp["op"], e: FileOpError): string {
|
||||
return op === "upload"
|
||||
? t("op_upload_exists")
|
||||
: t("op_unzip_conflicts", { count: e.conflict_count ?? e.conflicts?.length ?? 0 });
|
||||
// A volume with nothing left leaves avail out of the error, the way the Job
|
||||
// omits a zero.
|
||||
case "volume_full":
|
||||
return e.need !== undefined && e.avail !== undefined
|
||||
? t("op_volume_full", { need: formatBytes(e.need), avail: formatBytes(e.avail) })
|
||||
return e.need !== undefined
|
||||
? t("op_volume_full", { need: formatBytes(e.need), avail: formatBytes(e.avail ?? 0) })
|
||||
: humanizeError({ status: 0, code: e.code, message: e.message });
|
||||
case "archive_invalid":
|
||||
return entry ? t("archive_invalid_entry", { entry }) : t("archive_invalid");
|
||||
@@ -24,9 +26,10 @@ export function opErrorText(op: FileOp["op"], e: FileOpError): string {
|
||||
case "archive_symlink":
|
||||
case "type_conflict":
|
||||
return t(e.code, { entry });
|
||||
// The Job's own condition reason rides in the message; a deadline is the
|
||||
// one worth its own words.
|
||||
// The Job's reason rides in the message; a deadline and a memory kill are
|
||||
// the ones worth their own words.
|
||||
case "job_failed":
|
||||
if (e.message.includes("OOMKilled")) return op === "unzip" ? t("job_out_of_memory_unzip") : t("job_out_of_memory");
|
||||
return e.message.includes("DeadlineExceeded") ? t("job_timed_out") : t("job_failed");
|
||||
default:
|
||||
return humanizeError({ status: 0, code: e.code, message: e.message });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { FileOp, FileUploadSession } from "@/lib/types";
|
||||
import { MAX_ATTEMPTS } from "@/lib/contextUpload";
|
||||
@@ -29,14 +30,26 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const KEY = "felis-file-upload:lobby:world.zip";
|
||||
const PART = 4;
|
||||
const NOW = 1_000_000_000;
|
||||
const BODY = "0123456789";
|
||||
|
||||
// A 10-byte file sent in parts of 4: offsets 0, 4 and 8.
|
||||
function file(body = "0123456789", modified = 111) {
|
||||
function file(body = BODY, modified = 111) {
|
||||
return new File([body], "world.zip", { lastModified: modified });
|
||||
}
|
||||
|
||||
// The parts a server holding the first `received` bytes of body lists, as
|
||||
// they went up: PART bytes each, the last one short, hashed by Node.
|
||||
function partsOf(received: number, body = BODY) {
|
||||
const parts: FileUploadSession["parts"] = [];
|
||||
for (let at = 0; at < received; at += PART) {
|
||||
const end = Math.min(at + PART, received);
|
||||
parts.push({ size: end - at, sha256: createHash("sha256").update(body.slice(at, end)).digest("hex") });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function session(received: number, over: Partial<FileUploadSession> = {}): FileUploadSession {
|
||||
return { id: "s1", path: "world.zip", size: 10, received, part_max_bytes: PART, ...over };
|
||||
return { id: "s1", path: "world.zip", size: 10, received, part_max_bytes: PART, parts: partsOf(received), ...over };
|
||||
}
|
||||
|
||||
function op(over: Partial<FileOp> = {}): FileOp {
|
||||
@@ -134,6 +147,75 @@ describe("sendInParts", () => {
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s7", false]]);
|
||||
});
|
||||
|
||||
it("hashes the parts a remembered session holds against the file before carrying on", async () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7" }));
|
||||
const seen: number[] = [];
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts({ onProgress: (n) => seen.push(n) }));
|
||||
|
||||
// 4 and 8 as each held part checks out, 8 where the session stands, 10 sent.
|
||||
expect(seen).toEqual([4, 8, 8, 10]);
|
||||
expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(offsets()).toEqual([8]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a part of another file of the same name, size and time", partsOf(8, "abcd4567")],
|
||||
["a later part of another file", partsOf(8, "0123x567")],
|
||||
["fewer parts than the bytes it says it holds", partsOf(4)],
|
||||
])("gives back a remembered session holding %s, and starts the file over", async (_label, parts) => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7", parts }));
|
||||
mocks.deleteServerFileUpload.mockResolvedValue(null);
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s7"]]);
|
||||
expect(mocks.beginServerFileUpload.mock.calls).toEqual([["lobby", "world.zip", 10]]);
|
||||
expect(offsets()).toEqual([0, 4, 8]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", false]]);
|
||||
expect(stored().id).toBe("s1");
|
||||
});
|
||||
|
||||
it("stops while hashing what a remembered session holds, and keeps it for later", async () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
const ctrl = new AbortController();
|
||||
mocks.getServerFileUpload.mockImplementation(async () => {
|
||||
ctrl.abort();
|
||||
return session(8, { id: "s7" });
|
||||
});
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts({ signal: ctrl.signal }))).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
|
||||
expect(offsets()).toEqual([]);
|
||||
expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(stored().id).toBe("s7");
|
||||
});
|
||||
|
||||
it("sends a part changed on the way again, hashing none of the parts already known", async () => {
|
||||
let first = true;
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => {
|
||||
if (offset === 4 && first) {
|
||||
first = false;
|
||||
throw { status: 400, code: "digest_mismatch", message: "" };
|
||||
}
|
||||
return session(offset + part.size, { id });
|
||||
});
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(4));
|
||||
const seen: number[] = [];
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts({ onProgress: (n) => seen.push(n) }));
|
||||
|
||||
expect(offsets()).toEqual([0, 4, 4, 8]);
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000]);
|
||||
// No 4 from hashing the first part again: this run sent it.
|
||||
expect(seen).toEqual([0, 4, 4, 8, 10]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", false]]);
|
||||
});
|
||||
|
||||
it("commits at once when the server already holds the whole file", async () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(10, { id: "s7" }));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api, clientError } from "@/lib/api";
|
||||
import { sha256Of } from "@/lib/digest";
|
||||
import { MAX_ATTEMPTS, isTransient, retryDelay, wait } from "@/lib/contextUpload";
|
||||
import type { FileOp, FileUploadSession } from "@/lib/types";
|
||||
|
||||
@@ -11,8 +12,14 @@ import type { FileOp, FileUploadSession } from "@/lib/types";
|
||||
// The session is also remembered in this browser, under the server and path it
|
||||
// lands at, with the file's size and modification time. Choosing the same file
|
||||
// again after a reload, a closed tab or a lost connection carries on from the
|
||||
// bytes already sent. A remembered session no page has touched for a while is
|
||||
// what a refusal for too many sessions gives back first.
|
||||
// bytes already sent, once those have been hashed again against the file: the
|
||||
// session lists each part it holds with its SHA-256, and a session holding
|
||||
// anything else (another file of the same name, size and time) is given back
|
||||
// and the file starts over. A remembered session no page has touched for a
|
||||
// while is what a refusal for too many sessions gives back first.
|
||||
//
|
||||
// Every part goes with its SHA-256, and one the server hashes differently
|
||||
// (digest_mismatch: changed on the way) is sent again.
|
||||
|
||||
type Sleep = (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
|
||||
@@ -153,12 +160,23 @@ export async function sendInParts(server: string, path: string, file: File, opts
|
||||
let offset: number | null = null;
|
||||
let partMax = 0;
|
||||
let failures = 0;
|
||||
// known is how many bytes at the head of the session this run has seen to be
|
||||
// the file's own: parts it sent, or held parts it hashed again. A session
|
||||
// begun during the run holds only parts the run sent, so a new one needs no
|
||||
// reset.
|
||||
let known = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
if (offset === null) {
|
||||
const at = await standing(server, path, file, id, now);
|
||||
id = at.id;
|
||||
opts.onSession?.(id);
|
||||
if (!(await holdsTheFile(file, at, known, onProgress, signal))) {
|
||||
await discardSession(server, path, at.id, sleep);
|
||||
id = null;
|
||||
continue;
|
||||
}
|
||||
known = at.received;
|
||||
partMax = at.part_max_bytes;
|
||||
offset = at.received;
|
||||
onProgress?.(offset);
|
||||
@@ -171,12 +189,14 @@ export async function sendInParts(server: string, path: string, file: File, opts
|
||||
onProgress: (sent) => onProgress?.(start + sent),
|
||||
});
|
||||
offset = at.received;
|
||||
known = at.received;
|
||||
failures = 0;
|
||||
remember({ server, path, id: id!, size, modified: file.lastModified, touched: now() });
|
||||
onProgress?.(offset);
|
||||
} catch (e) {
|
||||
// The session went away under the upload (felis-api restarted, or it sat
|
||||
// idle too long): the next pass begins a new one.
|
||||
// idle too long): the next pass begins a new one. A part changed on the
|
||||
// way was not kept, so the next pass sends it again (isTransient).
|
||||
if (code(e) === "upload_not_found") {
|
||||
id = null;
|
||||
} else if (!isTransient(e)) {
|
||||
@@ -220,11 +240,34 @@ async function standing(
|
||||
return at;
|
||||
}
|
||||
|
||||
// holdsTheFile hashes the parts session at holds past the first known bytes
|
||||
// against the same ranges of file, and answers whether all of them match.
|
||||
// onProgress walks up through the parts as they check out.
|
||||
async function holdsTheFile(
|
||||
file: File,
|
||||
at: FileUploadSession,
|
||||
known: number,
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
let start = 0;
|
||||
for (const part of at.parts) {
|
||||
const end = start + part.size;
|
||||
if (end > known) {
|
||||
if (signal?.aborted) throw new DOMException("The upload was cancelled", "AbortError");
|
||||
if ((await sha256Of(file.slice(start, end))).hex !== part.sha256) return false;
|
||||
onProgress?.(end);
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
return start === at.received;
|
||||
}
|
||||
|
||||
// commit lands the session. A commit whose answer was lost may still have
|
||||
// started the Job, so asking again is read in that light: the world held
|
||||
// (maintenance_in_progress) by an upload of this path running now, or the
|
||||
// session gone because that Job already fetched it, means the first commit
|
||||
// went through, and its op is the answer.
|
||||
// session gone because that Job reported the file landed, means the first
|
||||
// commit went through, and its op is the answer.
|
||||
async function commit(
|
||||
server: string,
|
||||
path: string,
|
||||
|
||||
@@ -3,7 +3,7 @@ import i18next from "i18next";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { FileOpError, ServerFileEntry } from "@/lib/types";
|
||||
import { joinPath } from "./names";
|
||||
import { joinPath, nameTooLong } from "./names";
|
||||
import { opErrorText } from "./opText";
|
||||
import { discardSession, forgetSession, sendInParts, watchOp } from "./sessionUpload";
|
||||
|
||||
@@ -187,6 +187,10 @@ export function useUploads(server: string, { onLanded, onOp, hold = false, free
|
||||
retryable: false,
|
||||
landing: null,
|
||||
};
|
||||
// The server would refuse it only after the bytes were sent.
|
||||
if (nameTooLong(file.name)) {
|
||||
return { ...base, state: "failed", error: t("upload_name_too_long") };
|
||||
}
|
||||
const there = entries?.find((e) => e.name === file.name);
|
||||
if (there?.is_dir) {
|
||||
return { ...base, state: "failed", error: t("upload_folder_there") };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { usePolling } from "@/lib/hooks";
|
||||
import type { ServerJob } from "@/lib/types";
|
||||
import { OP_POLL_MS } from "./sessionUpload";
|
||||
|
||||
/** holdsWorld says whether a Job keeps the world from being changed, as the
|
||||
* server counts it (maintenance.JobKind): a running backup, restore, world
|
||||
* export or file download. A backup export reads only the backup store. A
|
||||
* safety snapshot whose restore has yet to start holds it for that restore. */
|
||||
export function holdsWorld(j: ServerJob): boolean {
|
||||
return j.then_restore === "pending" || (j.state === "running" && j.kind !== "export_backup");
|
||||
}
|
||||
|
||||
/** restores says whether the holder is (or leads to) a restore, which replaces
|
||||
* the files the page lists. */
|
||||
function restores(j: ServerJob): boolean {
|
||||
return j.kind === "restore" || j.then_restore === "pending";
|
||||
}
|
||||
|
||||
/** holderText is the files key naming what holds the world and what to wait
|
||||
* for. */
|
||||
export function holderText(j: ServerJob): string {
|
||||
if (restores(j)) return "wait_for_restore";
|
||||
switch (j.kind) {
|
||||
case "backup":
|
||||
return "wait_for_backup";
|
||||
case "export_world":
|
||||
return "wait_for_world_export";
|
||||
default:
|
||||
return "wait_for_file_download";
|
||||
}
|
||||
}
|
||||
|
||||
// useWorldJobs follows the Jobs that hold a server's world besides the file
|
||||
// manager's own ops (useFileOps): read once when `enabled` turns on, then every
|
||||
// OP_POLL_MS while one holds it. onRestored runs when a restore seen here ends.
|
||||
export function useWorldJobs(server: string, enabled: boolean, onRestored: () => void) {
|
||||
const [holder, setHolder] = useState<ServerJob | null>(null);
|
||||
const restoring = useRef(false);
|
||||
const restored = useRef(onRestored);
|
||||
restored.current = onRestored;
|
||||
// As in useFileOps: only the newest read lands, and none starts over another.
|
||||
const seq = useRef(0);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const read = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const ticket = ++seq.current;
|
||||
try {
|
||||
const jobs = await api.serverJobs(server);
|
||||
if (ticket !== seq.current) return;
|
||||
const h = jobs.find(holdsWorld) ?? null;
|
||||
setHolder(h);
|
||||
const now = h !== null && restores(h);
|
||||
if (restoring.current && !now) restored.current();
|
||||
restoring.current = now;
|
||||
} catch {
|
||||
// A list that fails leaves the page as it was: a change the world cannot
|
||||
// take is still refused, in the server's words.
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [server]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) void read();
|
||||
else setHolder(null);
|
||||
}, [enabled, read]);
|
||||
|
||||
const poll = useCallback(() => void read(), [read]);
|
||||
usePolling(poll, enabled && holder !== null ? OP_POLL_MS : null);
|
||||
|
||||
return { holder, refresh: poll };
|
||||
}
|
||||
@@ -25,14 +25,14 @@
|
||||
"no_backup": "There's no restorable backup for this server yet.",
|
||||
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
|
||||
"not_stopped": "Stop the server completely first — this changes its world volume, which the running server holds.",
|
||||
"maintenance_in_progress": "This server's world is busy with a restore, backup, world export, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; a world export lasts until its download ends, and an idle reclaim can take longer on a large world.",
|
||||
"maintenance_in_progress": "This server's world is busy with a restore, backup, world export, file download, file change or idle reclaim. Try again once it finishes. A restore, backup or file change usually takes a minute or two; an export or a download lasts until the browser has all of it, and an idle reclaim can take longer on a large world.",
|
||||
"no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.",
|
||||
"file_changed": "This file changed after you opened it (another manager saved it, or the server rewrote it on its last run), so your save was not written, to keep that change.",
|
||||
"volume_full": "The server's volume is full, so the change was not written and the files there are unchanged. Delete files it no longer needs, or ask an admin to grow its volume.",
|
||||
"backup_cooldown": "This server was backed up moments ago, and manual backups have a cooldown — try again in a few minutes.",
|
||||
"backup_store_full": "The backup store is full, so manual backups are paused — ask an administrator to free space.",
|
||||
"restore_unavailable": "Restore isn't available right now — try again later.",
|
||||
"export_busy": "Too many downloads are being prepared right now (one at a time per person, six an hour) — try again in a few minutes.",
|
||||
"export_busy": "Too many exports are being prepared: one at a time per person, two at a time across the platform, and six an hour per person. Try again in a few minutes.",
|
||||
"export_expired": "This download has expired or was already used — start the export again.",
|
||||
"export_not_ready": "The download isn't ready yet — wait a moment and try again.",
|
||||
"export_unavailable": "Downloading worlds and backups isn't set up on this deployment — ask an administrator.",
|
||||
@@ -71,6 +71,10 @@
|
||||
"upload_staging_full": "The panel's upload space is nearly full right now, so the file was not passed on. Try again later, or ask an admin to free space on the uploads volume.",
|
||||
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.",
|
||||
"length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.",
|
||||
"digest_mismatch": "The file was changed on its way to the server, so it was refused and nothing was written. Try again.",
|
||||
"digest_required": "The upload came without a checksum, so it was refused. Reload the panel and upload it again.",
|
||||
"bad_digest": "The upload's checksum was malformed, so it was refused. Reload the panel and upload it again.",
|
||||
"file_unreadable": "The file could not be read: it was changed, moved or deleted after it was picked. Pick it again and upload.",
|
||||
"upload_not_found": "This upload is gone: it was cancelled, already landed, sat idle for 6 hours, or the panel service restarted. Upload the file again.",
|
||||
"too_many_uploads": "You already have 4 large uploads in progress. Wait for one to finish, or cancel one, and try again.",
|
||||
"op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.",
|
||||
|
||||
@@ -79,10 +79,15 @@
|
||||
"upload_clear_done": "Clear finished",
|
||||
"upload_progress_label": "Uploading {{name}}",
|
||||
"upload_folder_there": "A folder with this name is already here.",
|
||||
"upload_name_too_long": "This file's name is longer than the 255 bytes a file name on the server can hold. Rename it shorter, then upload it.",
|
||||
"upload_no_room": "The world volume has {{free}} free, not enough for this {{size}} file. Delete files you do not need, then retry.",
|
||||
"upload_landing_progress": "Writing it to the server… {{percent}}%",
|
||||
"wait_for_op": "Wait for the background operation to finish first.",
|
||||
"wait_for_download": "Wait until the download is ready first.",
|
||||
"wait_for_backup": "A backup of this world is running. Change files once it finishes.",
|
||||
"wait_for_restore": "This world is being restored. Change files once the restore finishes.",
|
||||
"wait_for_world_export": "This world is being exported. Change files once that download ends.",
|
||||
"wait_for_file_download": "A download is reading this world. Change files once it has finished.",
|
||||
"unzip_item": "Extract {{name}} here",
|
||||
"unzip_conflicts_title_one": "Extracting {{name}} replaces {{count}} file",
|
||||
"unzip_conflicts_title_other": "Extracting {{name}} replaces {{count}} files",
|
||||
@@ -97,7 +102,8 @@
|
||||
"download_started_config": "Downloading {{filename}}. paper-global.yml, the proxy forwarding secret every server shares, is left out.",
|
||||
"download_failed": "The download could not be prepared.",
|
||||
"download_failed_because": "The download could not be prepared: {{reason}}",
|
||||
"download_busy": "Too many file downloads are being prepared (two at a time per person, thirty an hour). Try again in a few minutes.",
|
||||
"download_out_of_memory": "This folder holds more files than the zipping task has memory to list, so the system stopped it and nothing was downloaded. Download its folders one by one instead.",
|
||||
"download_busy": "Too many file downloads are being prepared: two at a time per person, four at a time across the platform, and thirty an hour per person. Try again in a few minutes.",
|
||||
"secret_config_no_download": "config/paper-global.yml holds the proxy forwarding secret every server shares, so it cannot be downloaded.",
|
||||
"ops_label": "Background operations",
|
||||
"ops_refresh_failed": "Could not read the background operations: {{reason}}",
|
||||
@@ -121,5 +127,7 @@
|
||||
"archive_symlink": "{{entry}} in the archive is a symbolic link. Nothing in the archive was extracted.",
|
||||
"type_conflict": "{{entry}} is a file on one side and a folder on the other, which replacing cannot resolve. Rename or delete {{entry}} here, then extract again.",
|
||||
"job_failed": "The background task ended without saying why. Refresh the list to check, then try again.",
|
||||
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again."
|
||||
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.",
|
||||
"job_out_of_memory": "The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.",
|
||||
"job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one."
|
||||
}
|
||||
@@ -25,14 +25,14 @@
|
||||
"no_backup": "这台服务器暂时没有可回档的备份。",
|
||||
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
|
||||
"not_stopped": "请先把服务器完全停止——这项操作要改动世界存储卷,运行中的服务器独占着它。",
|
||||
"maintenance_in_progress": "这台服务器的世界正在回档、备份、导出、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,导出要等下载结束,闲置回收视世界大小可能更久。",
|
||||
"maintenance_in_progress": "这台服务器的世界正在回档、备份、导出、下载文件、改动文件或闲置回收,等它完成后再试。回档、备份和改动文件通常一两分钟,导出和下载要等浏览器下载完,闲置回收视世界大小可能更久。",
|
||||
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
|
||||
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
|
||||
"volume_full": "服务器的存储卷已满,这次改动没有写入,原有文件保持不变。删掉用不着的文件,或者请管理员给它扩容。",
|
||||
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
|
||||
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
|
||||
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
||||
"export_busy": "现在正在准备的下载太多了(每人同时一个、每小时最多六个)——请过几分钟再试。",
|
||||
"export_busy": "正在准备的导出太多了:每人同时一个,整个平台同时最多两个,每人每小时最多六个。请过几分钟再试。",
|
||||
"export_expired": "这个下载已过期或已经用过了——请重新导出。",
|
||||
"export_not_ready": "下载还没准备好——请稍等片刻再试。",
|
||||
"export_unavailable": "当前部署没有开通世界和备份下载——请联系管理员。",
|
||||
@@ -71,6 +71,10 @@
|
||||
"upload_staging_full": "面板的上传暂存空间快满了,这个文件没有转存过去。稍后再试,或者请管理员清理上传卷。",
|
||||
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
|
||||
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
|
||||
"digest_mismatch": "文件在传输途中被改动了,服务器已拒收,什么都没有写入。请重试。",
|
||||
"digest_required": "这次上传没有附带校验值,被拒绝了。请刷新面板后重新上传。",
|
||||
"bad_digest": "这次上传附带的校验值格式不对,被拒绝了。请刷新面板后重新上传。",
|
||||
"file_unreadable": "读不出这个文件:它在选中之后被改动、移走或删除了。请重新选择文件再上传。",
|
||||
"upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。",
|
||||
"too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。",
|
||||
"op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。",
|
||||
|
||||
@@ -78,10 +78,15 @@
|
||||
"upload_clear_done": "清除已完成",
|
||||
"upload_progress_label": "正在上传 {{name}}",
|
||||
"upload_folder_there": "这里已经有同名文件夹。",
|
||||
"upload_name_too_long": "这个文件名超过 255 字节,服务器上的文件名存不下。先改短再上传。",
|
||||
"upload_no_room": "世界卷只剩 {{free}},放不下这个 {{size}} 的文件。先删掉些用不着的文件再重试。",
|
||||
"upload_landing_progress": "正在写入服务器… {{percent}}%",
|
||||
"wait_for_op": "等后台操作完成后再改动。",
|
||||
"wait_for_download": "等下载准备好后再操作。",
|
||||
"wait_for_backup": "正在备份这台服务器的世界,备份结束后才能改动文件。",
|
||||
"wait_for_restore": "正在回档这台服务器的世界,回档结束后才能改动文件。",
|
||||
"wait_for_world_export": "正在导出这台服务器的世界,那边的下载结束后才能改动文件。",
|
||||
"wait_for_file_download": "有一个下载正在读取这台服务器的世界,传完后才能改动文件。",
|
||||
"unzip_item": "把 {{name}} 解压到此处",
|
||||
"unzip_conflicts_title": "解压 {{name}} 会覆盖 {{count}} 个文件",
|
||||
"unzip_conflicts_body": "压缩包里的这些文件在这里已经存在。确认后会用压缩包里的版本替换它们,其余文件照常解压。旧版本之后可能还要用的话,先做一次备份。",
|
||||
@@ -95,7 +100,8 @@
|
||||
"download_started_config": "已开始下载 {{filename}}。所有服务器共用的代理转发密钥 paper-global.yml 不在里面。",
|
||||
"download_failed": "下载没有准备好。",
|
||||
"download_failed_because": "下载没有准备好:{{reason}}",
|
||||
"download_busy": "正在准备的文件下载太多了(每人同时两个、每小时最多三十个),请过几分钟再试。",
|
||||
"download_out_of_memory": "这个文件夹里的文件太多,打包任务的内存装不下它的文件清单,被系统停掉了,什么都没下载。请分成几个小一些的文件夹分别下载。",
|
||||
"download_busy": "正在准备的文件下载太多了:每人同时最多两个,整个平台同时最多四个,每人每小时最多三十个。请过几分钟再试。",
|
||||
"secret_config_no_download": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,不能下载。",
|
||||
"ops_label": "后台操作",
|
||||
"ops_refresh_failed": "读取后台操作失败:{{reason}}",
|
||||
@@ -117,5 +123,7 @@
|
||||
"archive_symlink": "压缩包里的 {{entry}} 是符号链接。整个压缩包都没有解压。",
|
||||
"type_conflict": "压缩包里的 {{entry}} 和这里已有的同名项一个是文件、一个是文件夹,覆盖解决不了。先把这里的 {{entry}} 改名或删掉再解压。",
|
||||
"job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。",
|
||||
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。"
|
||||
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。",
|
||||
"job_out_of_memory": "后台任务用完了内存,被系统停掉了。服务器上的文件没有被改动,再试一次。",
|
||||
"job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。"
|
||||
}
|
||||
+32
-18
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary
|
||||
@@ -658,20 +659,6 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ display_name: "new submission" });
|
||||
});
|
||||
|
||||
it("uploadSubmissionContext POSTs Blob to /me/submissions/{id}/context", async () => {
|
||||
const sub = { id: "sub-3", display_name: "new submission", status: "pending_review" };
|
||||
const fetchSpy = fakeFetch(sub);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const blob = new Blob(["test"], { type: "application/x-gzip" });
|
||||
const res = await api.uploadSubmissionContext("sub-3", blob);
|
||||
expect(res).toEqual(sub);
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/me/submissions/sub-3/context");
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
expect((opts as RequestInit).body).toBe(blob);
|
||||
expect((opts as RequestInit).headers).toEqual({ "Content-Type": "application/x-gzip" });
|
||||
});
|
||||
|
||||
// The lane's two throttled outcomes (a spent allowance, a closed cooldown)
|
||||
// must surface as their own copy, not the generic forbidden/error text.
|
||||
it("maps the submission quota/cooldown codes to stable human copy", async () => {
|
||||
@@ -1214,6 +1201,10 @@ async function sentXHR(): Promise<FakeXHR> {
|
||||
return FakeXHR.last as FakeXHR;
|
||||
}
|
||||
|
||||
// What felis-api's parseContentDigest takes: the body's SHA-256, base64, in
|
||||
// RFC 9530's sha-256=:…: form. Hashed here by Node, apart from the panel's own.
|
||||
const contentDigest = (body: string) => `sha-256=:${createHash("sha256").update(body).digest("base64")}:`;
|
||||
|
||||
describe("chunked context upload", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -1243,7 +1234,7 @@ describe("chunked context upload", () => {
|
||||
expect((opts as RequestInit).method).toBe("POST");
|
||||
});
|
||||
|
||||
it("putContextPart PUTs the part at its offset with the session cookie and reports progress", async () => {
|
||||
it("putContextPart PUTs the part at its offset with the session cookie, its SHA-256 and reports progress", async () => {
|
||||
const part = new Blob(["abcd"]);
|
||||
const seen: number[] = [];
|
||||
const done = api.putContextPart("sub-3", 8, part, { onProgress: (n) => seen.push(n) });
|
||||
@@ -1251,7 +1242,7 @@ describe("chunked context upload", () => {
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/me/submissions/sub-3/context/upload?offset=8");
|
||||
expect(xhr.withCredentials).toBe(true);
|
||||
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream" });
|
||||
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream", "Content-Digest": contentDigest("abcd") });
|
||||
expect(xhr.body).toBe(part);
|
||||
xhr.upload.onprogress?.({ loaded: 3 });
|
||||
xhr.respond(200, JSON.stringify(progress));
|
||||
@@ -1348,6 +1339,28 @@ describe("server file manager wire shapes", () => {
|
||||
return [String(url), opts as RequestInit];
|
||||
}
|
||||
|
||||
it("a file the browser can no longer read is refused before anything is sent", async () => {
|
||||
const changed = Object.assign(new Blob(["jar bytes"]), {
|
||||
arrayBuffer: () => Promise.reject(new DOMException("the file changed on disk", "NotReadableError")),
|
||||
});
|
||||
await expect(api.uploadServerFile("survival", "plugins/a.jar", changed, false)).rejects.toEqual({
|
||||
status: 0,
|
||||
code: "file_unreadable",
|
||||
message: "the file changed on disk",
|
||||
});
|
||||
await expect(api.putServerFileUploadPart("survival", "s1", 0, changed)).rejects.toMatchObject({
|
||||
code: "file_unreadable",
|
||||
});
|
||||
expect(FakeXHR.last).toBeUndefined();
|
||||
expect(humanizeError({ code: "file_unreadable" })).toMatch(/changed, moved or deleted/);
|
||||
});
|
||||
|
||||
it("the upload refusals over a checksum read as what to do next", () => {
|
||||
expect(humanizeError({ code: "digest_mismatch" })).toMatch(/changed on its way/);
|
||||
expect(humanizeError({ code: "digest_required" })).toMatch(/without a checksum/);
|
||||
expect(humanizeError({ code: "bad_digest" })).toMatch(/checksum was malformed/);
|
||||
});
|
||||
|
||||
it("createServerFile PUTs the content with create_only, so nothing already there is replaced", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "plugins/new.yml", status: "written", sha256: "c".repeat(64) });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
@@ -1406,7 +1419,7 @@ describe("server file manager wire shapes", () => {
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/servers/survival/files/upload?path=plugins%2FChunky%201.4.jar");
|
||||
expect(xhr.withCredentials).toBe(true);
|
||||
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream" });
|
||||
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream", "Content-Digest": contentDigest("jar bytes") });
|
||||
expect(xhr.body).toBe(file);
|
||||
xhr.upload.onprogress?.({ loaded: 4 });
|
||||
xhr.respond(200, JSON.stringify({ path: "plugins/Chunky 1.4.jar", status: "uploaded", sha256: "d".repeat(64), size: 9 }));
|
||||
@@ -1485,6 +1498,7 @@ describe("server file manager wire shapes", () => {
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/servers/survival/files/uploads/s1?offset=33554432");
|
||||
expect(xhr.withCredentials).toBe(true);
|
||||
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream", "Content-Digest": contentDigest("part bytes") });
|
||||
expect(xhr.body).toBe(part);
|
||||
xhr.upload.onprogress?.({ loaded: 3 });
|
||||
xhr.respond(200, JSON.stringify({ ...session, received: 33_554_442 }));
|
||||
@@ -1757,7 +1771,7 @@ describe("world export wire shapes", () => {
|
||||
|
||||
it("words the export refusals itself", () => {
|
||||
expect(humanizeError({ status: 429, code: "export_busy", message: "raw" })).toBe(
|
||||
"Too many downloads are being prepared right now (one at a time per person, six an hour) — try again in a few minutes.",
|
||||
"Too many exports are being prepared: one at a time per person, two at a time across the platform, and six an hour per person. Try again in a few minutes.",
|
||||
);
|
||||
expect(humanizeError({ status: 410, code: "export_expired", message: "raw" })).toBe(
|
||||
"This download has expired or was already used — start the export again.",
|
||||
|
||||
+45
-28
@@ -45,6 +45,7 @@ import type {
|
||||
UpdateReport,
|
||||
} from "./types";
|
||||
import { loadConfig } from "./config";
|
||||
import { sha256Of } from "./digest";
|
||||
import i18next from "i18next";
|
||||
|
||||
// Typed client for the felis-api external face (spec §7). Credentials are sent so
|
||||
@@ -231,28 +232,19 @@ function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
});
|
||||
}
|
||||
|
||||
function requestRaw<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: Blob,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<T> {
|
||||
return send<T>(path, { method, headers, body });
|
||||
}
|
||||
|
||||
// sendWithProgress sends body by XMLHttpRequest, the one browser API that
|
||||
// reports how much of a request body has gone out (fetch has no upload
|
||||
// progress), and settles the way send does: the parsed 2xx body, or the same
|
||||
// ApiError fetchOK would throw. onProgress gets the bytes of body sent so far;
|
||||
// signal aborts the request with an AbortError.
|
||||
// signal aborts the request with an AbortError; headers go along with it.
|
||||
async function sendWithProgress<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: Blob,
|
||||
opts: { onProgress?: (sent: number) => void; signal?: AbortSignal } = {},
|
||||
opts: { onProgress?: (sent: number) => void; signal?: AbortSignal; headers?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const { apiBase } = await loadConfig();
|
||||
const { onProgress, signal } = opts;
|
||||
const { onProgress, signal, headers } = opts;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const onAbort = () => xhr.abort();
|
||||
@@ -260,6 +252,7 @@ async function sendWithProgress<T>(
|
||||
xhr.open(method, `${apiBase}${path}`);
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
for (const [k, v] of Object.entries(headers ?? {})) xhr.setRequestHeader(k, v);
|
||||
if (onProgress) xhr.upload.onprogress = (e) => onProgress(e.loaded);
|
||||
xhr.onabort = () => {
|
||||
settle();
|
||||
@@ -297,6 +290,19 @@ async function sendWithProgress<T>(
|
||||
});
|
||||
}
|
||||
|
||||
// sendDigested sends body as sendWithProgress does, with its SHA-256 in
|
||||
// Content-Digest: felis-api hashes what arrives and keeps none of it on a
|
||||
// mismatch (400 digest_mismatch), so bytes changed on the way never land.
|
||||
async function sendDigested<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body: Blob,
|
||||
opts: { onProgress?: (sent: number) => void; signal?: AbortSignal } = {},
|
||||
): Promise<T> {
|
||||
const { header } = await sha256Of(body);
|
||||
return sendWithProgress<T>(method, path, body, { ...opts, headers: { "Content-Digest": header } });
|
||||
}
|
||||
|
||||
// rejectingSync turns a synchronous throw inside an api method (urlPath refusing
|
||||
// a segment) into a rejected promise, so every caller handles it the way it
|
||||
// handles any failed call.
|
||||
@@ -754,7 +760,7 @@ export const api = rejectingSync({
|
||||
// much room the world volume has (free_bytes), so an upload too big for it is
|
||||
// refused before it is sent.
|
||||
listServerFiles: (name: string, path: string) =>
|
||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean; free_bytes: number }>(
|
||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean; free_bytes: number | null }>(
|
||||
"GET",
|
||||
urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
@@ -815,8 +821,9 @@ export const api = rejectingSync({
|
||||
),
|
||||
|
||||
// uploadServerFile sends a file's raw bytes (the browser sets Content-Length
|
||||
// from the Blob) with progress. Without overwrite an existing file is 409
|
||||
// file_exists; with it the file is replaced whole or not at all.
|
||||
// from the Blob) and their SHA-256, with progress. Without overwrite an
|
||||
// existing file is 409 file_exists; with it the file is replaced whole or not
|
||||
// at all.
|
||||
uploadServerFile: (
|
||||
name: string,
|
||||
path: string,
|
||||
@@ -824,7 +831,7 @@ export const api = rejectingSync({
|
||||
overwrite: boolean,
|
||||
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
|
||||
) =>
|
||||
sendWithProgress<{ path: string; status: string; sha256: string; size: number }>(
|
||||
sendDigested<{ path: string; status: string; sha256: string; size: number }>(
|
||||
"PUT",
|
||||
urlPath`/servers/${name}/files/upload` +
|
||||
`?path=${encodeURIComponent(path)}` +
|
||||
@@ -835,10 +842,12 @@ export const api = rejectingSync({
|
||||
|
||||
// A file too big for one request goes up in parts (components/files/
|
||||
// sessionUpload.ts drives it): begin a session for its path and size, which
|
||||
// reserves room for all of it; put each part at its byte offset; then commit,
|
||||
// which answers at once with the op landing it (watch listServerFileOps). A
|
||||
// session answers only the account and server it was begun for, stays until
|
||||
// its file has been fetched whole once, and is dropped after 6 hours idle.
|
||||
// reserves room for all of it; put each part at its byte offset, with its
|
||||
// SHA-256; then commit, which answers at once with the op landing it (watch
|
||||
// listServerFileOps). A session answers only the account and server it was
|
||||
// begun for, and lists the parts it holds with their SHA-256. It stays until
|
||||
// the Job landing its file reports it landed, so a landing that fails can be
|
||||
// committed again, and is dropped after 6 hours idle.
|
||||
beginServerFileUpload: (name: string, path: string, size: number) =>
|
||||
request<FileUploadSession>(
|
||||
"POST",
|
||||
@@ -856,7 +865,7 @@ export const api = rejectingSync({
|
||||
part: Blob,
|
||||
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
|
||||
) =>
|
||||
sendWithProgress<FileUploadSession>(
|
||||
sendDigested<FileUploadSession>(
|
||||
"PUT",
|
||||
urlPath`/servers/${name}/files/uploads/${id}` + `?offset=${offset}`,
|
||||
part,
|
||||
@@ -1039,13 +1048,10 @@ export const api = rejectingSync({
|
||||
createSubmission: (displayName: string) =>
|
||||
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
||||
|
||||
uploadSubmissionContext: (id: string, file: Blob) =>
|
||||
requestRaw<Submission>("POST", urlPath`/me/submissions/${id}/context`, file, {
|
||||
"Content-Type": "application/x-gzip",
|
||||
}),
|
||||
|
||||
// A chunked context upload (lib/contextUpload.ts drives it): ask where the
|
||||
// staged upload stands, send each part at its byte offset, then store it.
|
||||
// staged upload stands, send each part at its byte offset, then store it. Each
|
||||
// part carries its SHA-256, and one changed on the way is refused
|
||||
// (digest_mismatch) and sent again.
|
||||
getContextUpload: (id: string) =>
|
||||
request<ContextUploadProgress>("GET", urlPath`/me/submissions/${id}/context/upload`),
|
||||
|
||||
@@ -1055,7 +1061,7 @@ export const api = rejectingSync({
|
||||
part: Blob,
|
||||
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
|
||||
) =>
|
||||
sendWithProgress<ContextUploadProgress>(
|
||||
sendDigested<ContextUploadProgress>(
|
||||
"PUT",
|
||||
urlPath`/me/submissions/${id}/context/upload` + `?offset=${offset}`,
|
||||
part,
|
||||
@@ -1371,6 +1377,17 @@ export function humanizeError(e: unknown): string {
|
||||
return t("upload_incomplete");
|
||||
case "length_required":
|
||||
return t("length_required");
|
||||
// Every upload body carries its SHA-256 (sendDigested): bytes that hash
|
||||
// differently on arrival are refused, and a file the browser can no longer
|
||||
// read (changed on disk since it was picked) is never sent.
|
||||
case "digest_mismatch":
|
||||
return t("digest_mismatch");
|
||||
case "digest_required":
|
||||
return t("digest_required");
|
||||
case "bad_digest":
|
||||
return t("bad_digest");
|
||||
case "file_unreadable":
|
||||
return t("file_unreadable");
|
||||
// An upload sent in parts: the session is gone (cancelled, landed, idle for
|
||||
// 6 hours, or felis-api restarted), or the account holds four already.
|
||||
case "upload_not_found":
|
||||
|
||||
@@ -144,6 +144,21 @@ describe("uploadContext", () => {
|
||||
expect(calls.completeContextUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends a part again when it arrived changed, from where the server stands", async () => {
|
||||
calls.getContextUpload.mockResolvedValueOnce(at(0)).mockResolvedValueOnce(at(4));
|
||||
acceptParts();
|
||||
calls.putContextPart.mockImplementationOnce(async (_id, offset: number, part: Blob) => at(offset + part.size));
|
||||
calls.putContextPart.mockRejectedValueOnce({ status: 400, code: "digest_mismatch", message: "" });
|
||||
await uploadContext("sub-1", FILE, { sleep });
|
||||
expect(await sentParts()).toEqual([
|
||||
[0, "0123"],
|
||||
[4, "4567"],
|
||||
[4, "4567"],
|
||||
[8, "89"],
|
||||
]);
|
||||
expect(calls.completeContextUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives up at once on a refusal the next attempt cannot outlast", async () => {
|
||||
calls.getContextUpload.mockResolvedValue(at(0));
|
||||
const refused = { status: 400, code: "bad_request", message: "context must be a gzip-compressed tarball" };
|
||||
|
||||
@@ -29,8 +29,9 @@ export const MAX_COMPLETE_WAITS = 40;
|
||||
|
||||
// A transient answer is one the next attempt can outlast: no response at all, a
|
||||
// tunnel or ingress page in place of the API's (upstream_unavailable), an uploads
|
||||
// store that did not answer the budget check (uploads_store_unavailable), or a
|
||||
// 409 that means "ask where the upload stands and send again".
|
||||
// store that did not answer the budget check (uploads_store_unavailable), a part
|
||||
// that arrived changed and was not kept (digest_mismatch), or a 409 that means
|
||||
// "ask where the upload stands and send again".
|
||||
export function isTransient(e: unknown): boolean {
|
||||
const err = e as Partial<ApiError> | null;
|
||||
if (!err || typeof err.code !== "string") return false;
|
||||
@@ -39,6 +40,7 @@ export function isTransient(e: unknown): boolean {
|
||||
err.code === "upstream_unavailable" ||
|
||||
err.code === "uploads_store_unavailable" ||
|
||||
err.code === "upload_busy" ||
|
||||
err.code === "digest_mismatch" ||
|
||||
err.code === "upload_offset_mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ApiError } from "./types";
|
||||
|
||||
// sha256Of hashes the bytes of blob the way felis-api checks an upload body:
|
||||
// `hex` is how the server lists the parts it holds, and `header` is the
|
||||
// Content-Digest value (RFC 9530) a request carries it in. A file changed or
|
||||
// removed on disk since it was picked cannot be read (the browser's
|
||||
// NotReadableError), which rejects as file_unreadable before anything is sent.
|
||||
export async function sha256Of(blob: Blob): Promise<{ hex: string; header: string }> {
|
||||
let bytes: ArrayBuffer;
|
||||
try {
|
||||
bytes = await blob.arrayBuffer();
|
||||
} catch (e) {
|
||||
const err: ApiError = { status: 0, code: "file_unreadable", message: e instanceof Error ? e.message : String(e) };
|
||||
throw err;
|
||||
}
|
||||
const sum = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||
let bin = "";
|
||||
let hex = "";
|
||||
for (const b of sum) {
|
||||
bin += String.fromCharCode(b);
|
||||
hex += b.toString(16).padStart(2, "0");
|
||||
}
|
||||
return { hex, header: `sha-256=:${btoa(bin)}:` };
|
||||
}
|
||||
@@ -148,12 +148,16 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Stream one staged file upload to the Job landing it (one-time bearer token).
|
||||
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist. An upload session committed through POST …/files/uploads/{id}/commit is fetched here the same way, under the session id; it stays staged until it has been sent whole once, so a Job that failed before then can be committed again.
|
||||
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist. An upload session committed through POST …/files/uploads/{id}/commit is fetched here the same way, under the session id; it stays staged until its Job reports the file landed (DELETE), so a Job that failed at any point can be committed again.
|
||||
*/
|
||||
get: operations["internalFileUpload"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
/**
|
||||
* The Job reports a staged upload landed (the same one-time bearer token).
|
||||
* @description Sent once the file is in place. An upload session is then dropped from felis-api's disk; a single-request upload goes when its request ends in any case. Only the token of the session's latest commit is taken. As for the fetch, every refusal is the same 404.
|
||||
*/
|
||||
delete: operations["internalFileUploadLanded"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -169,7 +173,7 @@ export interface paths {
|
||||
get?: never;
|
||||
/**
|
||||
* Hand one export's archive over for download (one-time bearer token).
|
||||
* @description The export Job PUTs the tar.gz here, chunked for a world and with its Content-Length for a backup. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the export is the whole check; an unknown id, a wrong or missing token and a token already used are all the same 404. The request then waits, body unread, up to 90 seconds for the owner's browser to open the download, and is read at the browser's pace: the 16 KiB/s minimum body rate does not apply, and the body fails only after 2 minutes without a byte. It answers once the download has ended.
|
||||
* @description The export Job PUTs the archive or file here, always chunked, with its size as X-Felis-Export-Length when it knows it and, once the body has ended, the SHA-256 of all it sent as the Content-Digest trailer (sha-256=:<base64>:). felis-api holds the last bytes back from the browser until the bytes it received number and hash as the Job said, so a body changed on the way, or one without the trailer, ends the download short and the browser reports it failed. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the export is the whole check; an unknown id, a wrong or missing token and a token already used are all the same 404. The request then waits, body unread, up to 90 seconds for the owner's browser to open the download, and is read at the browser's pace: the 16 KiB/s minimum body rate does not apply, and the body fails only after 2 minutes without a byte. It answers once the download has ended.
|
||||
*/
|
||||
put: operations["internalExportUpload"];
|
||||
post?: never;
|
||||
@@ -1343,7 +1347,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Download a ready export (once, by the user who started it).
|
||||
* @description The first request spends the ticket, whatever becomes of it. The archive streams as the Job sends it, with Content-Length when it is known; a download that cannot finish (the Job died, or a backup did not match its recorded sha256) is cut off, so the browser reports it failed. HEAD is refused, since it would spend the ticket on no body.
|
||||
* @description The first request spends the ticket, whatever becomes of it. The archive streams as the Job sends it, with Content-Length when it is known; a download that cannot finish (the Job died, a backup did not match its recorded sha256, or the bytes did not hash to the SHA-256 the Job sent with them) is cut off before its last bytes, so the browser reports it failed. HEAD is refused, since it would spend the ticket on no body.
|
||||
*/
|
||||
get: operations["exportDownload"];
|
||||
put?: never;
|
||||
@@ -1492,7 +1496,7 @@ export interface paths {
|
||||
get?: never;
|
||||
/**
|
||||
* Upload a file into a server's world volume (owner-or-admin; server must be stopped).
|
||||
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region; a bigger file goes up as an upload session (POST …/files/uploads). Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
|
||||
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region; a bigger file goes up as an upload session (POST …/files/uploads). Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. The body carries its SHA-256 as Content-Digest; felis-api checks it as the body arrives, and the Job checks the same digest again as it fetches the staged copy, so every hop between the browser and the world volume is verified. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
|
||||
*/
|
||||
put: operations["uploadServerFile"];
|
||||
post?: never;
|
||||
@@ -1536,7 +1540,7 @@ export interface paths {
|
||||
get: operations["getServerFileUpload"];
|
||||
/**
|
||||
* Send one part of an upload session (owner-or-admin, the account that began it).
|
||||
* @description The raw body is appended at offset, which must be where the session ends. Content-Length is required, and the part is taken whole or not at all: one cut short leaves the session where it was. Parts go one at a time (409 upload_busy while one arrives). Needs no stopped server, so starting the server midway costs only the commit's refusal until it is stopped again.
|
||||
* @description The raw body is appended at offset, which must be where the session ends. Content-Length and the part's own Content-Digest are required, and the part is taken whole or not at all: one cut short, or one whose bytes do not hash to its digest, leaves the session where it was. Parts go one at a time (409 upload_busy while one arrives). Needs no stopped server, so starting the server midway costs only the commit's refusal until it is stopped again.
|
||||
*/
|
||||
put: operations["putServerFileUploadPart"];
|
||||
post?: never;
|
||||
@@ -2362,7 +2366,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), and one withdrawn or deleted while its context streams in answers 404 with the bytes discarded; a wrong-format or oversize body is rejected with 400 (the per-upload cap is [registry] context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits reports it so a client can check a file before sending it). This request carries the whole context, so behind the Cloudflare edge, whose proxy refuses bodies over 100 MB with its own HTML 413 before they reach the API, a larger context goes through the chunked upload at /api/v1/me/submissions/{id}/context/upload instead. 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), and one withdrawn or deleted while its context streams in answers 404 with the bytes discarded; a wrong-format or oversize body is rejected with 400 (the per-upload cap is [registry] context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits reports it so a client can check a file before sending it). This request carries the whole context, so behind the Cloudflare edge, whose proxy refuses bodies over 100 MB with its own HTML 413 before they reach the API, a larger context goes through the chunked upload at /api/v1/me/submissions/{id}/context/upload instead. An upload that would push the caller past their per-user stored-context budget is refused with 403 before the excess is persisted. The body's SHA-256 is required as Content-Digest; bytes that do not hash to it were changed on the way, and none of them replace the context stored before. Returns 503 when the deployment's context store has no implemented upload transport.
|
||||
*/
|
||||
post: operations["uploadSubmissionContext"];
|
||||
delete?: never;
|
||||
@@ -2385,7 +2389,7 @@ export interface paths {
|
||||
get: operations["getContextUpload"];
|
||||
/**
|
||||
* Append one part of your chunked context upload.
|
||||
* @description The body is the part's raw bytes, at most part_max_bytes (32 MiB). offset is where they start: 0 starts the upload over, and anything else must equal the staged length, or the answer is 409 upload_offset_mismatch and the client reads GET for where to resume. The first part must open with the gzip magic (400). The staged total meets the same context cap (400) and storage budget (403) as a single upload. A part that breaks off is cut back off, so the staged bytes are always a prefix of the file. One request per upload at a time (409 upload_busy). Staged bytes untouched for 24 hours are deleted. The budget check reads blob sizes remembered for up to a minute; when a size has to be read and the uploads store does not answer, the answer is 503 uploads_store_unavailable with Retry-After, and the same part can be sent again.
|
||||
* @description The body is the part's raw bytes, at most part_max_bytes (32 MiB). offset is where they start: 0 starts the upload over, and anything else must equal the staged length, or the answer is 409 upload_offset_mismatch and the client reads GET for where to resume. The first part must open with the gzip magic (400). The staged total meets the same context cap (400) and storage budget (403) as a single upload. A part that breaks off is cut back off, and so is one whose bytes do not hash to its Content-Digest, so the staged bytes are always a prefix of the file. One request per upload at a time (409 upload_busy). Staged bytes untouched for 24 hours are deleted. The budget check reads blob sizes remembered for up to a minute; when a size has to be read and the uploads store does not answer, the answer is 503 uploads_store_unavailable with Retry-After, and the same part can be sent again.
|
||||
*/
|
||||
put: operations["putContextUploadPart"];
|
||||
post?: never;
|
||||
@@ -3054,6 +3058,12 @@ export interface components {
|
||||
* @description The most one part may carry.
|
||||
*/
|
||||
part_max_bytes: number;
|
||||
/** @description The parts taken so far, in order, each with the SHA-256 it arrived with. A client resuming from a file it still holds hashes the same ranges and starts over when one differs. */
|
||||
parts: {
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256: string;
|
||||
}[];
|
||||
};
|
||||
StartFileOp: {
|
||||
/** @description Replace files already there. */
|
||||
@@ -3817,12 +3827,32 @@ export interface operations {
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
internalFileUploadLanded: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description Bearer followed by the token the Job fetched the upload with. */
|
||||
Authorization: string;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
204: components["responses"]["NoContent"];
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
internalExportUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description Bearer followed by the token minted with the export. */
|
||||
Authorization: string;
|
||||
/** @description The body's length in bytes, when the Job knows it; the download then carries it as Content-Length. */
|
||||
"X-Felis-Export-Length"?: number;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
@@ -3836,6 +3866,15 @@ export interface operations {
|
||||
};
|
||||
responses: {
|
||||
204: components["responses"]["NoContent"];
|
||||
/** @description X-Felis-Export-Length is not a byte count (bad_request); the token is not spent. */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
404: components["responses"]["NotFound"];
|
||||
/** @description The backup did not match the sha256 recorded when it was written, and the download was aborted (backup_corrupt). */
|
||||
409: {
|
||||
@@ -3846,7 +3885,7 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Nobody opened the download within 90 seconds, or the browser left before the archive ended (export_expired). */
|
||||
/** @description Nobody opened the download within 90 seconds, the browser left before the archive ended, or what arrived did not number or hash as the Job declared, so the download was cut off (export_expired). */
|
||||
410: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -6800,7 +6839,7 @@ export interface operations {
|
||||
* Format: int64
|
||||
* @description Bytes free on the world volume
|
||||
*/
|
||||
free_bytes: number;
|
||||
free_bytes: number | null;
|
||||
entries: {
|
||||
name: string;
|
||||
/** Format: int64 */
|
||||
@@ -7325,7 +7364,10 @@ export interface operations {
|
||||
/** @description true replaces an existing file, keeping its mode. Anything else refuses to. */
|
||||
overwrite?: "true" | "false";
|
||||
};
|
||||
header?: never;
|
||||
header: {
|
||||
/** @description The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:. Other algorithms listed beside it are ignored. Bytes that do not hash to it were changed on the way and are refused whole. */
|
||||
"Content-Digest": string;
|
||||
};
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
@@ -7357,7 +7399,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Missing path, invalid server name, a folder or the world root at the path, a path that escapes the world root, or a body that ended before Content-Length bytes arrived (upload_incomplete). */
|
||||
/** @description Missing path, invalid server name, a folder or the world root at the path, a path that escapes the world root, a body that ended before Content-Length bytes arrived (upload_incomplete), no Content-Digest (digest_required), a malformed one (bad_digest), or bytes that do not hash to it (digest_mismatch; nothing is staged, so send it again). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -7558,7 +7600,10 @@ export interface operations {
|
||||
/** @description The byte position the part starts at, the session's received. */
|
||||
offset: number;
|
||||
};
|
||||
header?: never;
|
||||
header: {
|
||||
/** @description The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:. Other algorithms listed beside it are ignored. Bytes that do not hash to it were changed on the way and are refused whole. */
|
||||
"Content-Digest": string;
|
||||
};
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
@@ -7580,7 +7625,7 @@ export interface operations {
|
||||
"application/json": components["schemas"]["FileUploadSession"];
|
||||
};
|
||||
};
|
||||
/** @description A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), or a malformed server name (bad_name). */
|
||||
/** @description A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), no Content-Digest (digest_required), a malformed one (bad_digest), bytes that do not hash to it (digest_mismatch; the part was not taken, so send it again), or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -9728,7 +9773,10 @@ export interface operations {
|
||||
uploadSubmissionContext: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
header: {
|
||||
/** @description The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:. Other algorithms listed beside it are ignored. */
|
||||
"Content-Digest": string;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
@@ -9749,7 +9797,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Submission"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
/** @description A body that is not a gzip tarball or is over the context cap (bad_request), no Content-Digest (digest_required), a malformed one (bad_digest), or bytes that do not hash to it (digest_mismatch; nothing was stored, so send it again). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description The upload would exceed the caller's per-user stored-context budget (submission_quota_exceeded), which counts their pending and rejected uploads; approved ones leave it. */
|
||||
403: {
|
||||
@@ -9801,7 +9857,10 @@ export interface operations {
|
||||
query: {
|
||||
offset: number;
|
||||
};
|
||||
header?: never;
|
||||
header: {
|
||||
/** @description The SHA-256 of the part as RFC 9530 sends it, sha-256=:<base64>:. Other algorithms listed beside it are ignored. */
|
||||
"Content-Digest": string;
|
||||
};
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
@@ -9822,7 +9881,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["ContextUploadProgress"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
/** @description A missing or malformed offset, a first part without the gzip magic or a total over the context cap (bad_request), no Content-Digest (digest_required), a malformed one (bad_digest), or bytes that do not hash to it (digest_mismatch; the part was cut back off, so read where the upload stands and send it again). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
/** @description The staged total would exceed the caller's per-user stored-context budget (submission_quota_exceeded). */
|
||||
403: {
|
||||
|
||||
@@ -420,13 +420,15 @@ export interface ServerFileEntry {
|
||||
|
||||
/** FileUploadSession is where an upload sent in parts stands
|
||||
* (internal/api/handlers_fileops.go fileSessionView): the next part starts at
|
||||
* `received` and carries at most `part_max_bytes`. */
|
||||
* `received` and carries at most `part_max_bytes`. `parts` are the parts taken
|
||||
* so far, in order, each with the SHA-256 (hex) it arrived with. */
|
||||
export interface FileUploadSession {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
received: number;
|
||||
part_max_bytes: number;
|
||||
parts: { size: number; sha256: string }[];
|
||||
}
|
||||
|
||||
/** FileOp is one background upload landing or extraction
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -10,7 +11,7 @@ import { ONE_REQUEST_BYTES } from "@/components/files/useUploads";
|
||||
import { OP_POLL_MS } from "@/components/files/sessionUpload";
|
||||
import { EXPORT_POLL_MS } from "@/lib/download";
|
||||
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
import type { FileOp, ServerJob } from "@/lib/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeServerFile: vi.fn(),
|
||||
@@ -33,6 +34,7 @@ const mocks = vi.hoisted(() => ({
|
||||
putServerFileUploadPart: vi.fn(),
|
||||
deleteServerFileUpload: vi.fn(),
|
||||
commitServerFileUpload: vi.fn(),
|
||||
serverJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
@@ -61,6 +63,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
putServerFileUploadPart: mocks.putServerFileUploadPart,
|
||||
deleteServerFileUpload: mocks.deleteServerFileUpload,
|
||||
commitServerFileUpload: mocks.commitServerFileUpload,
|
||||
serverJobs: mocks.serverJobs,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -86,6 +89,8 @@ async function openEditor() {
|
||||
}
|
||||
|
||||
let opsNow: FileOp[] = [];
|
||||
// The server's backup, restore and export Jobs, as each read finds them.
|
||||
let jobsNow: ServerJob[] = [];
|
||||
const fileOp = (over: Partial<FileOp> = {}): FileOp => ({
|
||||
id: "op1",
|
||||
op: "unzip",
|
||||
@@ -136,6 +141,9 @@ beforeEach(() => {
|
||||
opsNow = [];
|
||||
mocks.listServerFileOps.mockReset();
|
||||
mocks.listServerFileOps.mockImplementation(async () => ({ ops: opsNow }));
|
||||
jobsNow = [];
|
||||
mocks.serverJobs.mockReset();
|
||||
mocks.serverJobs.mockImplementation(async () => jobsNow);
|
||||
localStorage.clear();
|
||||
mocks.stop.mockReset();
|
||||
mocks.status.mockReset();
|
||||
@@ -679,7 +687,7 @@ describe("ServerFiles uploads", () => {
|
||||
Object.defineProperty(f, "size", { value: size });
|
||||
return f;
|
||||
};
|
||||
const withFree = (free: number) =>
|
||||
const withFree = (free: number | null) =>
|
||||
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
@@ -700,6 +708,18 @@ describe("ServerFiles uploads", () => {
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a name past 255 bytes without sending it, and sends one at 255", async () => {
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
// A Mac counts 86 characters, the server 258 bytes; 85 of them are 255.
|
||||
pick(file("界".repeat(86)), file("界".repeat(85)));
|
||||
|
||||
expect(await within(queue()).findByText(t("files:upload_name_too_long"))).toBeTruthy();
|
||||
expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull();
|
||||
await waitFor(() => expect(sentAs()).toEqual([["界".repeat(85), false]]));
|
||||
});
|
||||
|
||||
it("refuses what the volume has no room for, counting the files ahead in the batch, and retries once there is room", async () => {
|
||||
withFree(100);
|
||||
renderFiles();
|
||||
@@ -720,7 +740,7 @@ describe("ServerFiles uploads", () => {
|
||||
});
|
||||
|
||||
it("sends when the listing could not tell how much room there is", async () => {
|
||||
withFree(0);
|
||||
withFree(null);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
@@ -729,6 +749,17 @@ describe("ServerFiles uploads", () => {
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
|
||||
});
|
||||
|
||||
it("refuses everything but an empty file on a full volume", async () => {
|
||||
withFree(0);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
pick(sized("a.jar", 1), sized("empty.txt", 0));
|
||||
|
||||
expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "0 B", size: "1 B" }))).toBeTruthy();
|
||||
await waitFor(() => expect(sentAs()).toEqual([["empty.txt", false]]));
|
||||
});
|
||||
|
||||
it("keeps a retry refused while the latest listing has no room for the file", async () => {
|
||||
withFree(10);
|
||||
renderFiles();
|
||||
@@ -759,7 +790,24 @@ describe("ServerFiles uploads", () => {
|
||||
const PART = 32 * 1024 * 1024;
|
||||
const SIZE = ONE_REQUEST_BYTES + 1;
|
||||
const KEY = "felis-file-upload:lobby:world.zip";
|
||||
const at = (received: number) => ({ id: "s1", path: "world.zip", size: SIZE, received, part_max_bytes: PART });
|
||||
// sized() keeps the five bytes file() made, and a slice past them is empty:
|
||||
// those are the bytes a resume hashes the held parts against.
|
||||
const held = (received: number) => {
|
||||
const out: { size: number; sha256: string }[] = [];
|
||||
for (let start = 0; start < received; start += PART) {
|
||||
const end = Math.min(start + PART, received);
|
||||
out.push({ size: end - start, sha256: createHash("sha256").update("bytes".slice(start, end)).digest("hex") });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const at = (received: number) => ({
|
||||
id: "s1",
|
||||
path: "world.zip",
|
||||
size: SIZE,
|
||||
received,
|
||||
part_max_bytes: PART,
|
||||
parts: held(received),
|
||||
});
|
||||
let parts: { offset: number; signal?: AbortSignal }[];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1425,6 +1473,15 @@ describe("ServerFiles archives and downloads", () => {
|
||||
() => i18next.t("files:download_failed_because", { reason: "tar: world: Cannot open" }),
|
||||
],
|
||||
["failed without one", () => mocks.exportStatus.mockResolvedValue({ state: "failed" }), () => t("files:download_failed")],
|
||||
[
|
||||
"killed for memory",
|
||||
() =>
|
||||
mocks.exportStatus.mockResolvedValue({
|
||||
state: "failed",
|
||||
message: "the job ran out of memory and the system stopped it (OOMKilled)",
|
||||
}),
|
||||
() => t("files:download_out_of_memory"),
|
||||
],
|
||||
])("says why a download could not be prepared when %s", async (_label, arrange, words) => {
|
||||
const clicked = watchLinks();
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" });
|
||||
@@ -1444,3 +1501,118 @@ describe("ServerFiles archives and downloads", () => {
|
||||
expect(button("download_folder_item", "world").disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles with the world held elsewhere", () => {
|
||||
const job = (over: Partial<ServerJob>): ServerJob => ({ name: "j1", kind: "backup", state: "running", ...over });
|
||||
const poll = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
// The notice sits in a status region; other messages can be status regions too.
|
||||
const notice = (key: string) => screen.queryByText(t(key));
|
||||
const shown = async (key: string) => (await screen.findByText(t(key))).closest('[role="status"]') !== null;
|
||||
|
||||
it("holds every change while a backup runs, says so, and lets go once it ends", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
jobsNow = [job({ kind: "backup" })];
|
||||
mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 });
|
||||
renderFiles();
|
||||
await screen.findByText("server.properties");
|
||||
|
||||
expect(await shown("files:wait_for_backup")).toBe(true);
|
||||
for (const b of [button("new_file"), button("new_folder"), button("delete_item", "world")]) {
|
||||
expect(b.disabled).toBe(true);
|
||||
expect(b.title).toBe(t("files:wait_for_backup"));
|
||||
}
|
||||
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } });
|
||||
await poll();
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
const lists = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
jobsNow = [job({ kind: "backup", state: "succeeded" })];
|
||||
await poll();
|
||||
|
||||
await waitFor(() => expect(notice("files:wait_for_backup")).toBeNull());
|
||||
expect(button("new_folder").disabled).toBe(false);
|
||||
await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"]));
|
||||
// A backup changed nothing, so only the upload's own landing rereads.
|
||||
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
|
||||
});
|
||||
|
||||
it("rereads the folder once a restore ends", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
jobsNow = [job({ kind: "restore" })];
|
||||
renderFiles();
|
||||
await screen.findByText("server.properties");
|
||||
expect(await shown("files:wait_for_restore")).toBe(true);
|
||||
const lists = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
jobsNow = [job({ kind: "restore", state: "succeeded" })];
|
||||
await poll();
|
||||
|
||||
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1));
|
||||
expect(notice("files:wait_for_restore")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a safety snapshot whose restore has yet to start", job({ kind: "backup", state: "succeeded", then_restore: "pending" }), "files:wait_for_restore"],
|
||||
["a world export", job({ kind: "export_world" }), "files:wait_for_world_export"],
|
||||
["a file download", job({ kind: "export_files" }), "files:wait_for_file_download"],
|
||||
])("names %s as what holds it", async (_what, holder, text) => {
|
||||
jobsNow = [job({ name: "old", kind: "restore", state: "failed" }), holder];
|
||||
renderFiles();
|
||||
await screen.findByText("server.properties");
|
||||
|
||||
expect(await shown(text)).toBe(true);
|
||||
expect(button("new_file").title).toBe(t(text));
|
||||
});
|
||||
|
||||
it("is not held by a backup being downloaded, which reads only the backup store", async () => {
|
||||
jobsNow = [job({ kind: "export_backup" })];
|
||||
renderFiles();
|
||||
await screen.findByText("server.properties");
|
||||
await waitFor(() => expect(mocks.serverJobs).toHaveBeenCalled());
|
||||
|
||||
for (const key of ["wait_for_backup", "wait_for_restore", "wait_for_world_export", "wait_for_file_download"]) expect(notice(`files:${key}`)).toBeNull();
|
||||
expect(button("new_file").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an extraction", "unzip_item", mocks.unzipServerFile, humanizeError],
|
||||
["a download", "download_item", mocks.downloadServerFile, (e: unknown) => i18next.t("files:download_failed_because", { reason: humanizeError(e) })],
|
||||
] as const)("looks again when %s is refused because the world is held", async (_what, key, call, said) => {
|
||||
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({ path, truncated: false, entries: [{ name: "pack.zip", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }] }),
|
||||
);
|
||||
const held = { status: 409, code: "maintenance_in_progress", message: "a world export or file download is running" };
|
||||
call.mockRejectedValueOnce(held);
|
||||
renderFiles();
|
||||
await screen.findByText("pack.zip");
|
||||
await waitFor(() => expect(mocks.serverJobs).toHaveBeenCalledTimes(1));
|
||||
jobsNow = [job({ kind: "export_world" })];
|
||||
|
||||
fireEvent.click(button(key, "pack.zip"));
|
||||
|
||||
expect(await shown("files:wait_for_world_export")).toBe(true);
|
||||
expect(screen.getByText(said(held))).toBeTruthy();
|
||||
expect(button(key, "pack.zip").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("holds changes while its own download streams to the browser", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "server.properties" });
|
||||
mocks.exportStatus.mockResolvedValue({ state: "ready" });
|
||||
mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download");
|
||||
renderFiles();
|
||||
await screen.findByText("server.properties");
|
||||
await waitFor(() => expect(mocks.serverJobs).toHaveBeenCalledTimes(1));
|
||||
jobsNow = [job({ kind: "export_files" })];
|
||||
|
||||
fireEvent.click(button("download_item", "server.properties"));
|
||||
await waitFor(() => expect(mocks.downloadServerFile).toHaveBeenCalledTimes(1));
|
||||
expect(notice("files:wait_for_file_download")).toBeNull();
|
||||
await act(() => vi.advanceTimersByTimeAsync(EXPORT_POLL_MS));
|
||||
expect(mocks.exportDownloadURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(await shown("files:wait_for_file_download")).toBe(true);
|
||||
expect(button("new_file").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import { FileOps } from "@/components/files/FileOps";
|
||||
import { UploadQueue } from "@/components/files/UploadQueue";
|
||||
import { useFileOps } from "@/components/files/useFileOps";
|
||||
import { useUploads } from "@/components/files/useUploads";
|
||||
import { holderText, useWorldJobs } from "@/components/files/useWorldJobs";
|
||||
import {
|
||||
SECRET_CONFIG_PATH,
|
||||
isManaged,
|
||||
@@ -133,7 +134,7 @@ export function ServerFiles() {
|
||||
const [listErr, setListErr] = useState<unknown>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
// Bytes free on the world volume by the latest listing; null while unknown
|
||||
// (the Job reports 0 when it could not tell).
|
||||
// (the listing says null when the Job could not tell; 0 is a full volume).
|
||||
const [free, setFree] = useState<number | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
@@ -151,7 +152,7 @@ export function ServerFiles() {
|
||||
if (ticket !== loadSeq.current) return;
|
||||
setEntries(sortEntries(r.entries ?? []));
|
||||
setTruncated(r.truncated === true);
|
||||
setFree(r.free_bytes > 0 ? r.free_bytes : null);
|
||||
setFree(r.free_bytes ?? null);
|
||||
setDir(p);
|
||||
} catch (e) {
|
||||
if (ticket !== loadSeq.current) return;
|
||||
@@ -308,6 +309,10 @@ export function ServerFiles() {
|
||||
}
|
||||
}
|
||||
const fileOps = useFileOps(name, owned && stopped, opEnded);
|
||||
// Backups, restores, world exports and downloads hold the world as well,
|
||||
// whichever tab or person started them. A restore replaces the files listed.
|
||||
const worldJobs = useWorldJobs(name, owned && stopped, () => void load(dir));
|
||||
const held = worldJobs.holder;
|
||||
|
||||
// A download holds the world while felis-api gets it ready, and a change
|
||||
// sent meanwhile could only be refused.
|
||||
@@ -329,20 +334,22 @@ export function ServerFiles() {
|
||||
landedSince.current = true;
|
||||
},
|
||||
onOp: fileOps.ignore,
|
||||
hold: fileOps.running || downloading !== null || unzipping !== null,
|
||||
hold: fileOps.running || downloading !== null || unzipping !== null || held !== null,
|
||||
free,
|
||||
});
|
||||
// Each change is a Job holding the world lock, so while anything else holds
|
||||
// it a change could only be refused.
|
||||
const changing = uploads.busy || fileOps.running || downloading !== null || unzipping !== null;
|
||||
// Names what holds the lock now: queued uploads wait on an op or a download
|
||||
// too, so those come first.
|
||||
const changing = uploads.busy || fileOps.running || downloading !== null || unzipping !== null || held !== null;
|
||||
// Names what holds the lock now: queued uploads wait on the rest too, so
|
||||
// those come first.
|
||||
const waitTitle =
|
||||
fileOps.running || unzipping !== null
|
||||
? t("wait_for_op")
|
||||
: downloading !== null
|
||||
? t("wait_for_download")
|
||||
: t("wait_for_uploads");
|
||||
: held !== null
|
||||
? t(holderText(held))
|
||||
: t("wait_for_uploads");
|
||||
useEffect(() => {
|
||||
if (uploads.busy || !landedSince.current) return;
|
||||
landedSince.current = false;
|
||||
@@ -456,6 +463,12 @@ export function ServerFiles() {
|
||||
if (op.state !== "running") opEnded(op);
|
||||
}
|
||||
|
||||
// A refusal because the world is held means something this page has not seen
|
||||
// holds it: reading the Jobs again names it and holds the buttons.
|
||||
function heldElsewhere(e: unknown) {
|
||||
if ((e as { code?: string }).code === "maintenance_in_progress") worldJobs.refresh();
|
||||
}
|
||||
|
||||
async function startUnzip(entry: ServerFileEntry) {
|
||||
const p = joinPath(dir, entry.name);
|
||||
setMsg(null);
|
||||
@@ -463,6 +476,7 @@ export function ServerFiles() {
|
||||
try {
|
||||
await unzip(p, false);
|
||||
} catch (e) {
|
||||
heldElsewhere(e);
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setUnzipping(null);
|
||||
@@ -488,13 +502,23 @@ export function ServerFiles() {
|
||||
const s = await awaitExport(tk.ticket, () => alive.current);
|
||||
if (s === null) return;
|
||||
if (s.state === "failed") {
|
||||
// A folder of more files than the zipping Job has memory to list gets it
|
||||
// killed, and felis-api names that OOMKilled; a single file streams through
|
||||
// in constant memory.
|
||||
const oom = s.message?.includes("OOMKilled");
|
||||
setMsg({
|
||||
kind: "error",
|
||||
text: s.message ? t("download_failed_because", { reason: s.message }) : t("download_failed"),
|
||||
text: oom
|
||||
? t("download_out_of_memory")
|
||||
: s.message
|
||||
? t("download_failed_because", { reason: s.message })
|
||||
: t("download_failed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename);
|
||||
// Its Job holds the world until the browser has all of it.
|
||||
worldJobs.refresh();
|
||||
const note =
|
||||
p === "server.properties"
|
||||
? "download_started_props"
|
||||
@@ -504,6 +528,7 @@ export function ServerFiles() {
|
||||
setMsg({ kind: "success", text: t(note, { filename: tk.filename }) });
|
||||
} catch (e) {
|
||||
if (!alive.current) return;
|
||||
heldElsewhere(e);
|
||||
setMsg({
|
||||
kind: "error",
|
||||
text:
|
||||
@@ -734,6 +759,15 @@ export function ServerFiles() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{held !== null && (
|
||||
<section role="status" className="border-b border-border bg-muted/20 px-4 py-2.5">
|
||||
<p className="flex items-start gap-2.5 text-sm">
|
||||
<Loader2 className="mt-0.5 h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
<span>{t(holderText(held))}</span>
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<FileOps
|
||||
ops={fileOps.ops}
|
||||
error={fileOps.error}
|
||||
|
||||
Reference in new issue
Block a user