diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index 1ed6770..d5a1095 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -210,6 +210,19 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b return "", false } + // World-volume gate, matching the backup/restore faces: the Job mounts the + // world PVC by claim name, so a server that has never started (or was already + // reaped) has no claim to mount and its Pod sits Pending until the executor's + // wait expires — a knowably impossible request answered by a 90s hang and a + // misleading 504. Refuse up front with the same specific 409. + if exists, err := a.Cluster.WorldVolumeExists(r.Context(), name); err != nil { + writeError(w, r, err) + return "", false + } else if !exists { + writeError(w, r, errNoWorldVolume()) + return "", false + } + // Files is optional: when unwired the endpoints report 503 rather than // panicking, so the authorization boundary above is exercised even before the // file-Job executor is wired (see FileEditor). diff --git a/internal/api/handlers_files_test.go b/internal/api/handlers_files_test.go index 0d82900..986185d 100644 --- a/internal/api/handlers_files_test.go +++ b/internal/api/handlers_files_test.go @@ -119,6 +119,47 @@ func TestFileEditorStoppedGate(t *testing.T) { } } +// TestFileEditorWorldVolumeGate pins the second physical gate: a server with no +// world PVC (never started, or already reaped) has no claim for the Job to mount, +// so its Pod would sit Pending until the executor's wait timed out — a 90s hang +// and a misleading 504 files_timeout for a request that is knowably impossible. +// All three routes must refuse BEFORE creating a Job, with the same specific 409 +// the backup/restore faces use. +func TestFileEditorWorldVolumeGate(t *testing.T) { + owner := &Principal{UserID: "owner1", Email: "owner1@example.net", Role: "user"} + + routes := []struct { + name string + method string + path string + body string + }{ + {"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, + {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, + {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, + } + + for _, rt := range routes { + t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) { + api, _, cl, files := mkFiles() + cl.noWorld["survival"] = true + api.External = staticExternal{p: owner} + + var hdr map[string]string + if rt.body != "" { + hdr = jsonHeader + } + w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr) + if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" { + t.Fatalf("code = %d body %s", w.Code, w.Body.String()) + } + if files.calls != 0 { + t.Fatal("no claim to mount — the file Job must never be created") + } + }) + } +} + // TestFileEditorAuthorization pins who may touch a world's files. It is the same // owner-or-admin rule the backup routes enforce, and it must hold on all three // routes — a read-only route leaking another owner's config (an RCON password diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 94e21de..e68a87b 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -14,6 +14,7 @@ "console_unavailable": "Can't reach the server console right now — try again shortly.", "no_backup": "There's no restorable backup for this server yet.", "not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.", + "no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.", "restore_unavailable": "Restore isn't available right now — try again later.", "session_expired": "Your session expired — please sign in again.", "forbidden": "You are not allowed to do that.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 0a66ac2..1e0a772 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -14,6 +14,7 @@ "console_unavailable": "暂时无法连接服务器控制台,请稍后重试。", "no_backup": "这台服务器暂时没有可回档的备份。", "not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。", + "no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。", "restore_unavailable": "回档功能当前不可用,请稍后再试。", "session_expired": "会话已过期——请重新登录。", "forbidden": "你无权执行此操作。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 2ef0a5a..a4a30ab 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -678,6 +678,8 @@ export function humanizeError(e: unknown): string { return t("no_backup"); case "not_stopped": return t("not_stopped"); + case "no_world_volume": + return t("no_world_volume"); case "restore_unavailable": return t("restore_unavailable"); default: