fix(files): 配置文件改为同目录临时文件 fsync 后原子改名写入,读取返回内容哈希、保存带期望哈希冲突返回 409,磁盘满返回 507,面板提示载入最新或仍然覆盖
This commit is contained in:
17 files changed
+721
-131
No files matched your search
@@ -20,6 +20,8 @@
|
||||
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
||||
"maintenance_in_progress": "This server's world is busy with a restore, backup, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; 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 save was not written and the file is unchanged. Ask an admin to grow or clean up this server's 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.",
|
||||
|
||||
@@ -19,5 +19,11 @@
|
||||
"saved": "Saved {{path}}",
|
||||
"saving": "Saving…",
|
||||
"save": "Save",
|
||||
"too_large": "Exceeds the {{limit}} editor limit."
|
||||
"too_large": "Exceeds the {{limit}} editor limit.",
|
||||
"conflict_title": "Someone else changed this file",
|
||||
"conflict_body": "The file changed after you opened it (another manager saved it, or the server rewrote it on its last run). Your edits are still in the editor and have not been written.",
|
||||
"conflict_reload": "Load latest",
|
||||
"conflict_reload_hint": "Discard your edits and show the file as it is now",
|
||||
"conflict_overwrite": "Overwrite anyway",
|
||||
"conflict_overwrite_hint": "Replace their change with your version"
|
||||
}
|
||||
@@ -20,6 +20,8 @@
|
||||
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
||||
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
|
||||
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
|
||||
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
|
||||
"volume_full": "服务器的存储卷已满,这次保存没有写入,原文件保持不变。请联系管理员扩容或清理这台服务器的数据。",
|
||||
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
|
||||
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
|
||||
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
||||
|
||||
@@ -19,5 +19,11 @@
|
||||
"saved": "已保存 {{path}}",
|
||||
"saving": "保存中…",
|
||||
"save": "保存",
|
||||
"too_large": "超出编辑器 {{limit}} 上限。"
|
||||
"too_large": "超出编辑器 {{limit}} 上限。",
|
||||
"conflict_title": "文件已被其他人修改",
|
||||
"conflict_body": "你打开这个文件之后,它被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。你的修改还在编辑框里,没有写入。",
|
||||
"conflict_reload": "载入最新内容",
|
||||
"conflict_reload_hint": "放弃你的修改,改看现在的文件",
|
||||
"conflict_overwrite": "仍然覆盖",
|
||||
"conflict_overwrite_hint": "用你的版本替换对方的修改"
|
||||
}
|
||||
@@ -714,6 +714,17 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect((opts as RequestInit).method).toBe("PUT");
|
||||
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" }));
|
||||
});
|
||||
|
||||
it("writeServerFile sends the hash the read returned as expect_sha256", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "a.txt", status: "written", sha256: "b".repeat(64) });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.writeServerFile("survival", "a.txt", "aGk=", "a".repeat(64));
|
||||
expect(res.sha256).toBe("b".repeat(64));
|
||||
const [, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect((opts as RequestInit).body).toBe(
|
||||
JSON.stringify({ content: "aGk=", expect_sha256: "a".repeat(64) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user passkey unbind wire shape", () => {
|
||||
|
||||
+19
-10
@@ -404,23 +404,25 @@ export const api = {
|
||||
`/servers/${name}/files?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
|
||||
// readServerFile returns one file's bytes (base64). A file over the read
|
||||
// ceiling is a 413, never a silent truncation, because a later save of a
|
||||
// truncated body would destroy the rest of the file.
|
||||
// readServerFile returns one file's bytes (base64) and the sha256 of the file
|
||||
// as stored. A file over the read ceiling is a 413, never a silent truncation,
|
||||
// because a later save of a truncated body would destroy the rest of the file.
|
||||
readServerFile: (name: string, path: string) =>
|
||||
request<{ path: string; content: string }>(
|
||||
request<{ path: string; content: string; sha256: string }>(
|
||||
"GET",
|
||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
|
||||
// writeServerFile replaces a file's contents (creating it if absent). Sending
|
||||
// an explicit "" is a deliberate truncate; the wire field is required, but that
|
||||
// is enforced by the caller (this method always sends one).
|
||||
writeServerFile: (name: string, path: string, content: string) =>
|
||||
request<{ path: string; status: string }>(
|
||||
// writeServerFile atomically replaces a file's contents (creating it if
|
||||
// absent). Sending an explicit "" is a deliberate truncate; the wire field is
|
||||
// required, but that is enforced by the caller (this method always sends one).
|
||||
// With expectSha256 (the hash the read returned) a file someone changed since
|
||||
// is refused with 409 file_changed; without it the write is unconditional.
|
||||
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
|
||||
request<{ path: string; status: string; sha256: string }>(
|
||||
"PUT",
|
||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||
{ content },
|
||||
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
|
||||
),
|
||||
|
||||
// Account linking (spec §10). Both are POST: start reports status from the
|
||||
@@ -756,6 +758,13 @@ export function humanizeError(e: unknown): string {
|
||||
return t("maintenance_in_progress");
|
||||
case "no_world_volume":
|
||||
return t("no_world_volume");
|
||||
// File editor: the file changed after it was opened (another manager saved
|
||||
// it, or the server rewrote it), so the save was refused rather than
|
||||
// overwriting that edit.
|
||||
case "file_changed":
|
||||
return t("file_changed");
|
||||
case "volume_full":
|
||||
return t("volume_full");
|
||||
// On-demand backup rationing (data-durability-9): one per server per
|
||||
// cooldown, none while the shared backup store is at its cap.
|
||||
case "backup_cooldown":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
@@ -139,29 +140,44 @@ export function ServerFiles() {
|
||||
}, [stopped, statusQ.reload]);
|
||||
|
||||
// Editor state. `editable` false marks a binary file (rendered read-only).
|
||||
// `sha256` is the hash the read returned: every save sends it back, so a file
|
||||
// someone changed in the meantime is refused (409 file_changed) and `conflict`
|
||||
// turns on instead of their edit being silently overwritten. `error` is a save
|
||||
// failure, shown inside the dialog where the person is looking.
|
||||
const [open, setOpen] = useState<{
|
||||
path: string;
|
||||
text: string;
|
||||
original: string;
|
||||
editable: boolean;
|
||||
sha256: string;
|
||||
conflict: boolean;
|
||||
error: string | null;
|
||||
} | null>(null);
|
||||
const [opening, setOpening] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
|
||||
async function readInto(p: string) {
|
||||
const r = await api.readServerFile(name, p);
|
||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
||||
setOpen({
|
||||
path: p,
|
||||
text: text ?? "",
|
||||
original: text ?? "",
|
||||
editable: text !== null,
|
||||
sha256: r.sha256 ?? "",
|
||||
conflict: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function openFile(entry: ServerFileEntry) {
|
||||
const p = joinPath(dir, entry.name);
|
||||
setOpening(p);
|
||||
setMsg(null);
|
||||
try {
|
||||
const r = await api.readServerFile(name, p);
|
||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
||||
setOpen({
|
||||
path: p,
|
||||
text: text ?? "",
|
||||
original: text ?? "",
|
||||
editable: text !== null,
|
||||
});
|
||||
await readInto(p);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
@@ -169,21 +185,42 @@ export function ServerFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// reloadOpen resolves a conflict by taking the file as it is now.
|
||||
async function reloadOpen() {
|
||||
if (!open || reloading) return;
|
||||
setReloading(true);
|
||||
try {
|
||||
await readInto(open.path);
|
||||
} catch (e) {
|
||||
setOpen({ ...open, error: humanizeError(e) });
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// handleSave writes the editor's text. `overwrite` drops the precondition,
|
||||
// which is what "Overwrite anyway" on a conflict means.
|
||||
async function handleSave(overwrite = false) {
|
||||
if (!open || saving) return;
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setOpen({ ...open, error: null });
|
||||
try {
|
||||
await api.writeServerFile(
|
||||
name,
|
||||
open.path,
|
||||
bytesToBase64(new TextEncoder().encode(open.text)),
|
||||
overwrite ? undefined : open.sha256 || undefined,
|
||||
);
|
||||
setMsg({ kind: "success", text: t("saved", { path: open.path }) });
|
||||
setOpen(null);
|
||||
void load(dir);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
if ((e as { code?: string }).code === "file_changed") {
|
||||
setOpen({ ...open, conflict: true, error: null });
|
||||
} else {
|
||||
setOpen({ ...open, error: humanizeError(e) });
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -394,7 +431,7 @@ export function ServerFiles() {
|
||||
)}
|
||||
|
||||
{/* File editor dialog */}
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && !saving && setOpen(null)}>
|
||||
<Dialog open={open !== null} onOpenChange={(v) => !v && !saving && !reloading && setOpen(null)}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-all font-mono text-sm">
|
||||
@@ -406,6 +443,51 @@ export function ServerFiles() {
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<>
|
||||
{open.conflict && (
|
||||
<div
|
||||
role="alert"
|
||||
className="grid gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-px h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("conflict_title")}</p>
|
||||
<p className="mt-0.5 text-foreground/80">{t("conflict_body")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pl-6">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void reloadOpen()}
|
||||
disabled={saving || reloading}
|
||||
title={t("conflict_reload_hint")}
|
||||
>
|
||||
{reloading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{t("conflict_reload")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void handleSave(true)}
|
||||
disabled={saving || reloading || tooLarge}
|
||||
title={t("conflict_overwrite_hint")}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("conflict_overwrite")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{open.error && <MessageLine kind="error" message={open.error} />}
|
||||
<textarea
|
||||
value={open.text}
|
||||
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
||||
@@ -423,13 +505,13 @@ export function ServerFiles() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(null)}
|
||||
disabled={saving}
|
||||
disabled={saving || reloading}
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !open.editable || !dirty || tooLarge}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
Reference in new issue
Block a user