diff --git a/panel/src/App.tsx b/panel/src/App.tsx index 01b2699..640f9ad 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -12,6 +12,7 @@ import { ServersPage } from "@/pages/servers/ServersPage"; import { ServerConsole } from "@/pages/ServerConsole"; import { ServerPlayers } from "@/pages/ServerPlayers"; import { ServerBackups } from "@/pages/ServerBackups"; +import { ServerFiles } from "@/pages/ServerFiles"; import { ServerLuckPerms } from "@/pages/ServerLuckPerms"; import { Account } from "@/pages/Account"; import { ImageAdmin } from "@/pages/admin/ImageAdmin"; @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/panel/src/i18n/index.ts b/panel/src/i18n/index.ts index d8f576e..d11e2e7 100644 --- a/panel/src/i18n/index.ts +++ b/panel/src/i18n/index.ts @@ -12,6 +12,7 @@ import enErrors from "./resources/en-US/errors.json"; import enNavigation from "./resources/en-US/navigation.json"; import enBackups from "./resources/en-US/backups.json"; import enSubmissions from "./resources/en-US/submissions.json"; +import enFiles from "./resources/en-US/files.json"; import zhCommon from "./resources/zh-CN/common.json"; import zhAuth from "./resources/zh-CN/auth.json"; import zhDashboard from "./resources/zh-CN/dashboard.json"; @@ -23,6 +24,7 @@ import zhErrors from "./resources/zh-CN/errors.json"; import zhNavigation from "./resources/zh-CN/navigation.json"; import zhBackups from "./resources/zh-CN/backups.json"; import zhSubmissions from "./resources/zh-CN/submissions.json"; +import zhFiles from "./resources/zh-CN/files.json"; i18next .use(LanguageDetector) @@ -41,6 +43,7 @@ i18next navigation: enNavigation, backups: enBackups, submissions: enSubmissions, + files: enFiles, }, "zh-CN": { common: zhCommon, @@ -54,6 +57,7 @@ i18next navigation: zhNavigation, backups: zhBackups, submissions: zhSubmissions, + files: zhFiles, }, }, fallbackLng: "en-US", diff --git a/panel/src/i18n/resources/en-US/files.json b/panel/src/i18n/resources/en-US/files.json new file mode 100644 index 0000000..a71e3a4 --- /dev/null +++ b/panel/src/i18n/resources/en-US/files.json @@ -0,0 +1,23 @@ +{ + "title": "Server Files", + "back_to_console": "Back to console", + "root": "World root", + "up": "Up", + "refresh": "Refresh", + "col_name": "Name", + "col_size": "Size", + "col_modified": "Modified", + "empty_dir_title": "This folder is empty", + "empty_dir_hint": "Nothing has been written here yet.", + "list_truncated": "The listing was truncated at the server's safety limit.", + "stopped_required_title": "The server is running", + "stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.", + "stop_server": "Stop server", + "not_yours_title": "Not your server", + "not_yours_body": "Only the owner or an admin can edit this server's files.", + "binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.", + "saved": "Saved {{path}}", + "saving": "Saving…", + "save": "Save", + "too_large": "Exceeds the {{limit}} editor limit." +} diff --git a/panel/src/i18n/resources/en-US/servers.json b/panel/src/i18n/resources/en-US/servers.json index 1cb5407..35a9a21 100644 --- a/panel/src/i18n/resources/en-US/servers.json +++ b/panel/src/i18n/resources/en-US/servers.json @@ -49,6 +49,8 @@ "luckperms_link_desc": "Manage player groups and fine-grained permission nodes.", "backups_link_title": "Backups & restore", "backups_link_desc": "View and restore world backups for this server.", + "files_link_title": "Server files", + "files_link_desc": "Browse and repair files in the world volume.", "players_back_to_console": "Back to console", "players_not_yours_title": "Not your server", "players_not_yours_body": "Only the owner or an admin can manage this server's players.", diff --git a/panel/src/i18n/resources/zh-CN/files.json b/panel/src/i18n/resources/zh-CN/files.json new file mode 100644 index 0000000..a710193 --- /dev/null +++ b/panel/src/i18n/resources/zh-CN/files.json @@ -0,0 +1,23 @@ +{ + "title": "服务器文件", + "back_to_console": "返回控制台", + "root": "世界根目录", + "up": "上级目录", + "refresh": "刷新", + "col_name": "名称", + "col_size": "大小", + "col_modified": "修改时间", + "empty_dir_title": "此目录为空", + "empty_dir_hint": "这里还没有写入任何内容。", + "list_truncated": "列表已按服务器安全上限截断。", + "stopped_required_title": "服务器正在运行", + "stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。", + "stop_server": "停止服务器", + "not_yours_title": "这不是你的服务器", + "not_yours_body": "只有服务器所有者或管理员才能编辑其文件。", + "binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。", + "saved": "已保存 {{path}}", + "saving": "保存中…", + "save": "保存", + "too_large": "超出编辑器 {{limit}} 上限。" +} diff --git a/panel/src/i18n/resources/zh-CN/servers.json b/panel/src/i18n/resources/zh-CN/servers.json index f22c73b..b615d74 100644 --- a/panel/src/i18n/resources/zh-CN/servers.json +++ b/panel/src/i18n/resources/zh-CN/servers.json @@ -49,6 +49,8 @@ "luckperms_link_desc": "管理玩家用户组与细粒度权限节点。", "backups_link_title": "备份与恢复", "backups_link_desc": "查看并恢复该服务器的世界备份。", + "files_link_title": "服务器文件", + "files_link_desc": "浏览并修复世界卷中的文件。", "players_back_to_console": "返回控制台", "players_not_yours_title": "这不是你的服务器", "players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 2cae3b1..e281262 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -582,6 +582,46 @@ describe("image whitelist and builds wire shapes", () => { }); }); + describe("server file editor wire shape", () => { + it("listServerFiles GETs /servers/{name}/files with the path as a query parameter", async () => { + const entries = [ + { name: "world", size: 0, is_dir: true, mod_time: "2026-07-03T12:00:00Z" }, + { name: "server.properties", size: 580, is_dir: false, mod_time: "2026-07-03T12:00:00Z" }, + ]; + const fetchSpy = fakeFetch({ path: "config", entries, truncated: false }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.listServerFiles("survival", "config/old stuff"); + expect(res.entries).toHaveLength(2); + expect(res.entries[1].is_dir).toBe(false); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + // "/" and " " must survive the query encoding — the path is a query value, + // never a path segment. + expect(String(url)).toBe("/servers/survival/files?path=config%2Fold%20stuff"); + expect((opts as RequestInit).method).toBe("GET"); + }); + + it("readServerFile GETs /servers/{name}/file and passes base64 through", async () => { + const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC" }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.readServerFile("survival", "world/level.dat"); + expect(res.content).toBe("AAEC"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat"); + expect((opts as RequestInit).method).toBe("GET"); + }); + + it("writeServerFile PUTs {content} — an explicit \"\" is a deliberate truncate, not an omitted field", async () => { + const fetchSpy = fakeFetch({ path: "a.txt", status: "written" }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.writeServerFile("survival", "a.txt", ""); + expect(res.status).toBe("written"); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/servers/survival/file?path=a.txt"); + expect((opts as RequestInit).method).toBe("PUT"); + expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" })); + }); + }); + describe("user passkey unbind wire shape", () => { it("unbindUserPasskeys DELETEs /users/{id}/passkeys", async () => { const fetchSpy = fakeFetch({ ok: true }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 0931daf..31f1df7 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -17,6 +17,7 @@ import type { PlayersResult, QuotaInput, QuotaView, + ServerFileEntry, ServerJob, ServerInfo, SessionView, @@ -359,6 +360,37 @@ export const api = { `/servers/${name}/jobs`, ).then((r) => r.jobs ?? []), + // Server file editor (spec §7). All three routes are owner-or-admin gated and + // refuse with 409 not_stopped unless the server is fully stopped (the world + // volume is RWO), so callers gate on phase === "Stopped". The path travels as a + // query parameter — a file path contains "/" and never round-trips through a + // path segment. Content is []byte on the wire, which Go's encoding/json renders + // as base64, so it is binary-safe in both directions. + listServerFiles: (name: string, path: string) => + request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>( + "GET", + `/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: (name: string, path: string) => + request<{ path: string; content: 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 }>( + "PUT", + `/servers/${name}/file?path=${encodeURIComponent(path)}`, + { content }, + ), + // Account linking (spec §10). Both are POST: start reports status from the // session principal (no body, side-effect-free), verify consumes a code the // player was shown in-game. The panel can never mint a code — that is the diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 8992a9d..ecc4373 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -236,6 +236,15 @@ export interface BindResult { export type BuildStatus = "pending" | "building" | "succeeded" | "failed" | "cancelled"; +/** ServerFileEntry mirrors fileedit.Entry — one row of a world-directory listing + * (GET /servers/{name}/files). */ +export interface ServerFileEntry { + name: string; + size: number; + is_dir: boolean; + mod_time: string; +} + /** Build mirrors an image_builds row (spec §6, §16). */ export interface Build { id: string; diff --git a/panel/src/pages/ServerConsole.tsx b/panel/src/pages/ServerConsole.tsx index 9f7a224..be9a9ad 100644 --- a/panel/src/pages/ServerConsole.tsx +++ b/panel/src/pages/ServerConsole.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useLayoutEffect, type KeyboardEvent } from "react"; import { Link, useParams } from "react-router-dom"; -import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, ChevronRight, type LucideIcon } from "lucide-react"; +import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, ChevronRight, type LucideIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; @@ -316,6 +316,21 @@ export function ServerConsole() { + + {/* File editor — its own subpage. Files are only reachable while the server + is stopped (the world volume is RWO), so the page itself owns that gate + and offers the stop action; the doorway shows at every phase. */} + + +
+

{t("files_link_title")}

+

{t("files_link_desc")}

+
+ + diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx new file mode 100644 index 0000000..88c25ba --- /dev/null +++ b/panel/src/pages/ServerFiles.tsx @@ -0,0 +1,448 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + ArrowUp, + ChevronRight, + FileText, + Folder, + FolderOpen, + Loader2, + RefreshCw, + Save, + Square, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { BackLink } from "@/components/BackLink"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { MessageLine } from "@/components/MessageLine"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { PhaseBadge } from "@/components/PhaseBadge"; +import { Loading, ErrorState, NotYours, EmptyState } from "@/components/States"; +import { PageHeader } from "@/components/PageHeader"; +import { api, humanizeError } from "@/lib/api"; +import { useAsync } from "@/lib/hooks"; +import { useTier } from "@/lib/tier"; +import { formatBytes, formatRelative } from "@/lib/format"; +import type { ServerFileEntry } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +/** The write ceiling, mirrored from fileedit.MaxWriteBytes (server truth). Reads + * are capped at 1 MiB server-side; a larger file is refused there with 413, so + * this only gates the save button to keep the common case honest up front. */ +const MAX_WRITE_BYTES = 256 * 1024; + +/** The []byte wire codec: Go's encoding/json renders []byte as base64, so the + * editor must speak it explicitly in both directions. Chunked so a ~256 KiB + * file never trips the argument-length ceiling of String.fromCharCode. */ +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(bin); +} + +function base64ToBytes(b64: string): Uint8Array { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** decodeText returns the file's text, or null when the bytes are not valid + * UTF-8 or contain NUL. Such files open read-only: a textarea round-trip would + * silently corrupt them, and this editor exists to REPAIR configs, never to + * damage data it does not understand. */ +function decodeText(bytes: Uint8Array): string | null { + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return text.includes("\u0000") ? null : text; + } catch { + return null; + } +} + +function joinPath(dir: string, name: string): string { + return dir === "" ? name : `${dir}/${name}`; +} + +function parentOf(dir: string): string { + const i = dir.lastIndexOf("/"); + return i === -1 ? "" : dir.slice(0, i); +} + +/** ServerFiles is the world-volume file editor (the "one wrong line in + * server.properties" repair). Every call is owner-or-admin gated and refused + * with 409 not_stopped unless the server is fully stopped (the world volume is + * RWO), so the page gates up front instead of letting each call fail. */ +export function ServerFiles() { + const { name = "" } = useParams(); + const { t, i18n } = useTranslation("files"); + const locale = i18n.language; + const { isAdmin, loading: tierLoading } = useTier(); + + const statusQ = useAsync(() => api.status(name), [name]); + const mineQ = useAsync( + () => (isAdmin ? Promise.resolve([]) : api.myServers()), + [isAdmin, name], + ); + const ownershipPending = tierLoading || (!isAdmin && mineQ.data === null && !mineQ.error); + const owned = isAdmin || (mineQ.data ?? []).some((s) => s.name === name && s.owned === true); + const stopped = statusQ.data?.phase === "Stopped"; + + const [dir, setDir] = useState(""); + const [entries, setEntries] = useState(null); + const [truncated, setTruncated] = useState(false); + const [listErr, setListErr] = useState(null); + const [listLoading, setListLoading] = useState(false); + const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); + + const load = useCallback( + async (p: string) => { + setListLoading(true); + setListErr(null); + try { + const r = await api.listServerFiles(name, p); + setEntries(r.entries ?? []); + setTruncated(r.truncated === true); + setDir(p); + } catch (e) { + setEntries(null); + setListErr(e); + } finally { + setListLoading(false); + } + }, + [name], + ); + + // Load (and reload after a stop) only once the viewer is resolved as owner and + // the server is fully stopped — both are hard server-side gates of every call. + useEffect(() => { + if (owned && stopped) void load(dir); + }, [owned, stopped, load]); + + // While the server is not stopped, poll the phase so the first successful + // stop flips the page from the notice to the listing without a manual reload. + useEffect(() => { + if (stopped) return; + const id = setInterval(() => statusQ.reload(), 4000); + return () => clearInterval(id); + }, [stopped, statusQ.reload]); + + // Editor state. `editable` false marks a binary file (rendered read-only). + const [open, setOpen] = useState<{ + path: string; + text: string; + original: string; + editable: boolean; + } | null>(null); + const [opening, setOpening] = useState(null); + const [saving, setSaving] = useState(false); + const [stopping, setStopping] = useState(false); + + 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, + }); + } catch (e) { + setMsg({ kind: "error", text: humanizeError(e) }); + } finally { + setOpening(null); + } + } + + async function handleSave() { + if (!open || saving) return; + setSaving(true); + setMsg(null); + try { + await api.writeServerFile( + name, + open.path, + bytesToBase64(new TextEncoder().encode(open.text)), + ); + setMsg({ kind: "success", text: t("saved", { path: open.path }) }); + setOpen(null); + void load(dir); + } catch (e) { + setMsg({ kind: "error", text: humanizeError(e) }); + } finally { + setSaving(false); + } + } + + async function handleStop() { + if (stopping) return; + setStopping(true); + setMsg(null); + try { + await api.stop(name); + statusQ.reload(); + } catch (e) { + setMsg({ kind: "error", text: humanizeError(e) }); + } finally { + setStopping(false); + } + } + + const back = ; + if (statusQ.loading && !statusQ.data) { + return ( + <> + {back} + + + ); + } + if (statusQ.error) { + return ( + <> + {back} + + + ); + } + if (!statusQ.data) return back; + + const now = Date.now(); + const segments = dir === "" ? [] : dir.split("/"); + const dirty = open !== null && open.text !== open.original; + const dirtyBytes = open ? new TextEncoder().encode(open.text).length : 0; + const tooLarge = dirtyBytes > MAX_WRITE_BYTES; + + const header = ( + } + className="mb-6" + /> + ); + + return ( + <> + {back} + {header} + {ownershipPending ? ( + + ) : mineQ.error ? ( + + ) : !owned ? ( + + ) : ( +
+ {msg && } + {!stopped ? ( + + + +
+

{t("stopped_required_title")}

+

+ {t("stopped_required_body")} +

+
+ +
+
+ ) : listLoading && entries === null ? ( + + ) : listErr ? ( + load(dir)} /> + ) : ( + + + {/* Location bar: parent button + clickable breadcrumbs + refresh */} +
+ + +
+ +
+
+ + {entries && entries.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + {(entries ?? []).map((e) => ( + (e.is_dir ? void load(joinPath(dir, e.name)) : void openFile(e))} + className="cursor-pointer transition-colors hover:bg-muted/30" + > + + + + + ))} + +
{t("col_name")}{t("col_size")}{t("col_modified")}
+ + {e.is_dir ? ( + + ) : ( + + )} + {e.name} + {opening === joinPath(dir, e.name) && ( + + )} + + + {e.is_dir ? "—" : formatBytes(e.size)} + + {e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"} +
+
+ )} + {truncated && ( +

+ {t("list_truncated")} +

+ )} +
+
+ )} +
+ )} + + {/* File editor dialog */} + !v && !saving && setOpen(null)}> + + + + {open?.path} + + {open && !open.editable && ( +

{t("binary_hint")}

+ )} +
+ {open && ( + <> +