diff --git a/scripts/request-analysis-worker.mjs b/scripts/request-analysis-worker.mjs index df56de3..c2148ed 100644 --- a/scripts/request-analysis-worker.mjs +++ b/scripts/request-analysis-worker.mjs @@ -17,6 +17,7 @@ const DEFAULTS = { modelBaseUrl: process.env.REQUEST_ANALYZER_MODEL_BASE_URL || "", model: process.env.REQUEST_ANALYZER_MODEL || "", apiKey: process.env.REQUEST_ANALYZER_API_KEY || process.env.DEEPSEEK_API_KEY || "", + adminPassword: process.env.REQUEST_ANALYZER_ADMIN_PASSWORD || process.env.LAUNCHER_ADMIN_PASSWORD || "", limit: Number(process.env.REQUEST_ANALYZER_LIMIT || 5), promoteThreshold: Number(process.env.REQUEST_ANALYZER_PROMOTE_THRESHOLD || 0.85), maxTokens: Math.max(512, Number(process.env.REQUEST_ANALYZER_MAX_TOKENS || 4000)), @@ -57,12 +58,14 @@ Environment variables: REQUEST_ANALYZER_MODEL_BASE_URL REQUEST_ANALYZER_MODEL REQUEST_ANALYZER_API_KEY + REQUEST_ANALYZER_ADMIN_PASSWORD REQUEST_ANALYZER_MAX_TOKENS REQUEST_ANALYZER_THINKING REQUEST_ANALYZER_LIMIT REQUEST_ANALYZER_INTERVAL_MS REQUEST_ANALYZER_PROMOTE_THRESHOLD DEEPSEEK_API_KEY + LAUNCHER_ADMIN_PASSWORD Notes: - DeepSeek uses ${DEFAULT_DEEPSEEK_BASE_URL}/chat/completions. @@ -194,6 +197,17 @@ async function fetchJson(url, init = {}) { return response.json(); } +function buildAdminHeaders(config, baseHeaders = {}) { + const nextHeaders = { + ...baseHeaders, + }; + const adminPassword = String(config?.adminPassword || "").trim(); + if (adminPassword) { + nextHeaders["x-worldshaper-admin-password"] = adminPassword; + } + return nextHeaders; +} + function tokenize(value) { return String(value || "") .toLowerCase() @@ -498,9 +512,9 @@ async function getLauncherRequests(config) { async function patchLauncherRequest(config, requestId, body) { return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}`), { method: "PATCH", - headers: { + headers: buildAdminHeaders(config, { "Content-Type": "application/json", - }, + }), body: JSON.stringify(body), }); } @@ -508,9 +522,9 @@ async function patchLauncherRequest(config, requestId, body) { async function processLauncherRequestAnalysis(config, requestId, body) { return fetchJson(buildUrl(config.apiBase, `/api/launcher-requests/${encodeURIComponent(requestId)}/process-analysis`), { method: "POST", - headers: { + headers: buildAdminHeaders(config, { "Content-Type": "application/json", - }, + }), body: JSON.stringify(body), }); } diff --git a/server.js b/server.js index 048f59e..e44af27 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,38 @@ const __dirname = path.dirname(__filename); const app = express(); const port = Number(process.env.PORT) || 5180; const host = process.env.HOST || "0.0.0.0"; +const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim(); + +function isLauncherAdminProtectionEnabled() { + return Boolean(launcherAdminPassword); +} + +function readLauncherAdminPasswordCandidate(req) { + const headerValue = req.get("x-worldshaper-admin-password"); + if (String(headerValue || "").trim()) { + return String(headerValue || "").trim(); + } + return String(req.body?.password || "").trim(); +} + +function requireLauncherAdminAccess(req, res) { + if (!isLauncherAdminProtectionEnabled()) { + res.status(503).json({ + error: "Launcher admin access is not configured on the server.", + adminConfigured: false, + }); + return false; + } + const submittedPassword = readLauncherAdminPasswordCandidate(req); + if (!submittedPassword || submittedPassword !== launcherAdminPassword) { + res.status(401).json({ + error: "Admin access denied.", + adminConfigured: true, + }); + return false; + } + return true; +} function resolveContentRoot() { const envPath = String(process.env.CONTENT_ROOT || "").trim(); @@ -2610,6 +2642,9 @@ app.get("/api/types", (_req, res) => { }); app.get("/api/debug/paths", (_req, res) => { + if (!requireLauncherAdminAccess(_req, res)) { + return; + } const contentFiles = Object.fromEntries( Object.entries(contentMap).map(([type, entry]) => { const fullPath = path.join(contentRoot, entry.file); @@ -2637,7 +2672,37 @@ app.get("/api/debug/paths", (_req, res) => { }); }); -app.get("/api/debug/recent-saves", (_req, res) => { +app.post("/api/admin/auth-check", (req, res) => { + if (!isLauncherAdminProtectionEnabled()) { + res.status(503).json({ + ok: false, + accessGranted: false, + adminConfigured: false, + error: "Launcher admin access is not configured on the server.", + }); + return; + } + const accessGranted = readLauncherAdminPasswordCandidate(req) === launcherAdminPassword; + if (!accessGranted) { + res.status(401).json({ + ok: false, + accessGranted: false, + adminConfigured: true, + error: "Admin access denied.", + }); + return; + } + res.json({ + ok: true, + accessGranted: true, + adminConfigured: true, + }); +}); + +app.get("/api/debug/recent-saves", (req, res) => { + if (!requireLauncherAdminAccess(req, res)) { + return; + } res.json({ ok: true, contentRoot, @@ -2701,6 +2766,9 @@ app.post("/api/launcher-requests", (req, res) => { app.patch("/api/launcher-requests/:requestId", (req, res) => { const requestId = String(req.params.requestId || "").trim(); + if (!requireLauncherAdminAccess(req, res)) { + return; + } try { const payload = readLauncherRequestsPayload(); const index = payload.requests.findIndex((entry) => entry.id === requestId); @@ -2745,6 +2813,9 @@ app.patch("/api/launcher-requests/:requestId", (req, res) => { app.delete("/api/launcher-requests/:requestId", (req, res) => { const requestId = String(req.params.requestId || "").trim(); + if (!requireLauncherAdminAccess(req, res)) { + return; + } try { const payload = readLauncherRequestsPayload(); const existing = payload.requests.find((entry) => entry.id === requestId); @@ -2775,6 +2846,9 @@ app.delete("/api/launcher-requests/:requestId", (req, res) => { app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => { const requestId = String(req.params.requestId || "").trim(); const action = String(req.body?.action || "").trim().toLowerCase(); + if (!requireLauncherAdminAccess(req, res)) { + return; + } try { const payload = readLauncherRequestsPayload(); const index = payload.requests.findIndex((entry) => entry.id === requestId); @@ -2866,7 +2940,10 @@ app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => { } }); -app.post("/api/launcher-requests/process-pending", (_req, res) => { +app.post("/api/launcher-requests/process-pending", (req, res) => { + if (!requireLauncherAdminAccess(req, res)) { + return; + } try { const result = launchQueuedRequestAnalysis("manual-api-trigger"); res.json({ diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index 3fd7b57..ad9ff12 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -95,6 +95,13 @@ type ProcessPendingPayload = { pid?: number; }; +type AdminAuthPayload = { + ok?: boolean; + accessGranted?: boolean; + adminConfigured?: boolean; + error?: string; +}; + const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; async function resolveDefaultWorldId(): Promise { @@ -122,6 +129,25 @@ async function fetchJsonOrThrow(input: RequestInfo | URL, init?: RequestInit) return response.json() as Promise; } +function buildAdminHeaders(password: string, headers?: HeadersInit): HeadersInit { + const normalizedPassword = String(password || "").trim(); + if (!normalizedPassword) { + return { + ...(headers || {}), + }; + } + return { + ...(headers || {}), + "x-worldshaper-admin-password": normalizedPassword, + }; +} + +function isAdminAccessError(error: unknown): boolean { + const text = String(error || "").toLowerCase(); + return text.includes("admin access denied") + || text.includes("admin access is not configured"); +} + function formatRequestTimestamp(value: string): string { const parsed = Date.parse(String(value || "")); if (!Number.isFinite(parsed)) { @@ -281,6 +307,11 @@ function WorldshaperLauncher() { const [requestFilter, setRequestFilter] = useState("all"); const [expandedRequestIds, setExpandedRequestIds] = useState([]); const [adminPanelOpen, setAdminPanelOpen] = useState(false); + const [adminAccessGranted, setAdminAccessGranted] = useState(false); + const [adminPassword, setAdminPassword] = useState(""); + const [adminPasswordDraft, setAdminPasswordDraft] = useState(""); + const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false); + const [adminPasswordError, setAdminPasswordError] = useState(""); const [recentSaveEvents, setRecentSaveEvents] = useState([]); const [logsLoading, setLogsLoading] = useState(false); const [logsError, setLogsError] = useState(""); @@ -329,19 +360,37 @@ function WorldshaperLauncher() { async function loadRecentSaveEvents(): Promise { setLogsLoading(true); try { - const payload = await fetchJsonOrThrow("/api/debug/recent-saves"); + const payload = await fetchJsonOrThrow("/api/debug/recent-saves", { + headers: buildAdminHeaders(adminPassword), + }); setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); setLogsError(""); } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + } setLogsError(String(nextError || "Failed to load admin logs.")); } finally { setLogsLoading(false); } } + async function verifyAdminPassword(password: string): Promise { + const payload = await fetchJsonOrThrow("/api/admin/auth-check", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ password }), + }); + if (!payload.accessGranted) { + throw new Error(String(payload.error || "Admin access denied.")); + } + } + async function refreshAdminData(options?: { includeLogs?: boolean; silentRequests?: boolean }): Promise { await loadRequests({ silent: options?.silentRequests === true }); - if (options?.includeLogs) { + if (options?.includeLogs && adminAccessGranted && adminPassword) { await loadRecentSaveEvents(); } } @@ -351,11 +400,11 @@ function WorldshaperLauncher() { }, []); useEffect(() => { - if (!adminPanelOpen) { + if (!adminPanelOpen || !adminAccessGranted || !adminPassword) { return; } void loadRecentSaveEvents(); - }, [adminPanelOpen]); + }, [adminPanelOpen, adminAccessGranted, adminPassword]); useEffect(() => { if (activeBoardTab !== "requests") { @@ -371,11 +420,13 @@ function WorldshaperLauncher() { } catch { // Keep the current list visible during background refresh failures. } - if (!adminPanelOpen) { + if (!adminPanelOpen || !adminAccessGranted || !adminPassword) { return; } try { - const payload = await fetchJsonOrThrow("/api/debug/recent-saves"); + const payload = await fetchJsonOrThrow("/api/debug/recent-saves", { + headers: buildAdminHeaders(adminPassword), + }); if (!cancelled) { setRecentSaveEvents(Array.isArray(payload.saves) ? payload.saves : []); } @@ -390,7 +441,7 @@ function WorldshaperLauncher() { cancelled = true; window.clearInterval(intervalId); }; - }, [activeBoardTab, adminPanelOpen]); + }, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]); async function handleLaunch(): Promise { setError(""); @@ -436,11 +487,11 @@ function WorldshaperLauncher() { setRequestDraftOpen(false); setRequestsError(""); setAdminNotice("Request saved. The VPS queue worker will pick it up if analysis autorun is enabled."); - if (adminPanelOpen) { + if (adminPanelOpen && adminAccessGranted) { void loadRecentSaveEvents(); } window.setTimeout(() => { - void refreshAdminData({ includeLogs: adminPanelOpen, silentRequests: true }); + void refreshAdminData({ includeLogs: adminPanelOpen && adminAccessGranted, silentRequests: true }); }, 3500); } catch (nextError: unknown) { setRequestsError(String(nextError || "Failed to save request.")); @@ -449,6 +500,45 @@ function WorldshaperLauncher() { } } + async function handleAdminPanelToggle(): Promise { + setRequestDraftOpen(false); + setRequestsError(""); + setLogsError(""); + if (adminPanelOpen) { + setAdminPanelOpen(false); + setAdminNotice(""); + return; + } + setAdminPanelOpen(true); + setAdminPasswordError(""); + if (adminAccessGranted && adminPassword) { + await refreshAdminData({ includeLogs: true, silentRequests: true }); + } + } + + async function handleAdminUnlock(): Promise { + const submittedPassword = adminPasswordDraft.trim(); + if (!submittedPassword) { + setAdminPasswordError("Enter the admin password to continue."); + return; + } + setAdminAuthSubmitting(true); + setAdminPasswordError(""); + try { + await verifyAdminPassword(submittedPassword); + setAdminPassword(submittedPassword); + setAdminAccessGranted(true); + setAdminNotice("Admin access granted."); + await refreshAdminData({ includeLogs: true, silentRequests: true }); + } catch (nextError: unknown) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError(String(nextError || "Failed to unlock the admin panel.")); + } finally { + setAdminAuthSubmitting(false); + } + } + function handleToggleExpandedRequest(requestId: string): void { setExpandedRequestIds((current) => ( current.includes(requestId) @@ -466,6 +556,7 @@ function WorldshaperLauncher() { try { const payload = await fetchJsonOrThrow(`/api/launcher-requests/${encodeURIComponent(requestEntry.id)}`, { method: "DELETE", + headers: buildAdminHeaders(adminPassword), }); setRequests(Array.isArray(payload.requests) ? payload.requests : []); setRequestsError(""); @@ -475,6 +566,11 @@ function WorldshaperLauncher() { void loadRecentSaveEvents(); } } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } setRequestsError(String(nextError || "Failed to delete request.")); } finally { setRequestMutatingId(""); @@ -486,6 +582,7 @@ function WorldshaperLauncher() { try { const payload = await fetchJsonOrThrow("/api/launcher-requests/process-pending", { method: "POST", + headers: buildAdminHeaders(adminPassword), }); if (payload.launched) { setAdminNotice(`Queue worker launched for ${payload.queuedPendingCount ?? 0} pending request${payload.queuedPendingCount === 1 ? "" : "s"}.`); @@ -508,6 +605,11 @@ function WorldshaperLauncher() { }, 4200); } } catch (nextError: unknown) { + if (isAdminAccessError(nextError)) { + setAdminAccessGranted(false); + setAdminPassword(""); + setAdminPasswordError("Admin access expired. Enter the password again."); + } setLogsError(String(nextError || "Failed to trigger the queue worker.")); } finally { setQueueTriggering(false); @@ -660,144 +762,185 @@ function WorldshaperLauncher() {
{requestCount} saved request{requestCount === 1 ? "" : "s"}: {queuedPendingRequestCount} queued, {needsReviewRequestCount} in review, and {activeRequestCount} active.
- -
-
+
+
-
{adminPanelOpen ? (
-
-
-
Moderation Tools
-

Admin Panel

+ {!adminAccessGranted ? ( +
+
Protected Tools
+

Admin Access Required

- Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place. + Enter the admin password to manage deletions, run the queue worker, and read request logs.

-
-
- {queuedPendingRequestCount} queued - {needsReviewRequestCount} review - {pendingRequestCount} pending - {activeRequestCount} active -
-
-
- - -
- {adminNotice ?

{adminNotice}

: null} - {logsError ?

{logsError}

: null} -
-
-
-

Request Management

-
Delete actions live here now.
+ +
+
-
- {requests.map((requestEntry) => { - const isMutating = requestMutatingId === requestEntry.id; - const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state); - return ( -
-
-
{requestEntry.title}
-
- {formatRequestStatusLabel(requestEntry.status)} - {requestEntry.category} - {analysisState} - {formatRequestTimestamp(requestEntry.updatedAt)} + {adminPasswordError ?

{adminPasswordError}

: null} +
+ ) : ( + <> +
+
+
Moderation Tools
+

Admin Panel

+

+ Run the VPS queue worker, review the latest request-analysis events, and manage deletions from one place. +

+
+
+ {queuedPendingRequestCount} queued + {needsReviewRequestCount} review + {pendingRequestCount} pending + {activeRequestCount} active +
+
+
+ + +
+ {adminNotice ?

{adminNotice}

: null} + {adminPasswordError ?

{adminPasswordError}

: null} + {logsError ?

{logsError}

: null} +
+
+
+

Request Management

+
Delete actions live here now.
+
+
+ {requests.map((requestEntry) => { + const isMutating = requestMutatingId === requestEntry.id; + const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state); + return ( +
+
+
{requestEntry.title}
+
+ {formatRequestStatusLabel(requestEntry.status)} + {requestEntry.category} + {analysisState} + {formatRequestTimestamp(requestEntry.updatedAt)} +
+
+ +
+ ); + })} +
+
+
+
+

Recent Logs

+
Newest events first.
+
+
+ {logsLoading && recentSaveEvents.length === 0 ? ( +
Loading admin logs...
+ ) : null} + {!logsLoading && recentSaveEvents.length === 0 ? ( +
No admin logs have been recorded yet.
+ ) : null} + {recentSaveEvents.map((eventEntry, index) => ( +
+
+
{formatEventLabel(eventEntry)}
+
{formatRequestTimestamp(String(eventEntry.at || ""))}
-
- -
- ); - })} +
{formatEventDetail(eventEntry) || "No extra details recorded."}
+ + ))} +
+
-
-
-
-

Recent Logs

-
Newest events first.
-
-
- {logsLoading && recentSaveEvents.length === 0 ? ( -
Loading admin logs...
- ) : null} - {!logsLoading && recentSaveEvents.length === 0 ? ( -
No admin logs have been recorded yet.
- ) : null} - {recentSaveEvents.map((eventEntry, index) => ( -
-
-
{formatEventLabel(eventEntry)}
-
{formatRequestTimestamp(String(eventEntry.at || ""))}
-
-
{formatEventDetail(eventEntry) || "No extra details recorded."}
-
- ))} -
-
-
+ + )} ) : null} - {requestDraftOpen ? ( + {requestDraftOpen && !adminPanelOpen ? (
) : null} -
+ {!adminPanelOpen ? ( +
{requestsLoading ? (
Loading saved requests...
@@ -908,10 +1052,11 @@ function WorldshaperLauncher() {
); }) : null} -
+
+ ) : null}
- Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs live in the admin panel. + Requests are saved and shared from this launcher. Public rows stay focused on the request itself, while moderation tools and logs stay behind protected admin access.
diff --git a/src/index.css b/src/index.css index 67216fb..0f08dfd 100644 --- a/src/index.css +++ b/src/index.css @@ -255,27 +255,12 @@ body { padding-right: 4px; } -.launcher-request-controls { - position: sticky; - top: 0; - z-index: 3; +.launcher-request-hero-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: center; - padding: 12px; - border: 1px solid #365782; - border-radius: 12px; - background: - linear-gradient(180deg, rgba(16, 29, 56, 0.96) 0%, rgba(12, 22, 43, 0.96) 100%); - box-shadow: - 0 10px 24px rgba(3, 8, 18, 0.26), - inset 0 0 0 1px rgba(10, 16, 32, 0.14); -} - -.launcher-request-toolbar { - display: flex; - justify-content: flex-start; + gap: 12px; + align-items: end; + margin-top: 16px; } .launcher-request-toolbar-buttons { @@ -329,6 +314,17 @@ body { inset 0 0 0 1px rgba(10, 16, 32, 0.14); } +.launcher-request-admin-unlock { + display: grid; + gap: 12px; + max-width: 420px; +} + +.launcher-request-admin-field { + display: grid; + gap: 4px; +} + .launcher-request-admin-head { display: flex; justify-content: space-between; @@ -1918,8 +1914,9 @@ button.danger:not(:disabled):hover { flex-direction: column; } - .launcher-request-controls { + .launcher-request-hero-actions { grid-template-columns: 1fr; + align-items: stretch; } .launcher-request-admin-head,