fix(panel): 路径参数逐段编码并拒绝点段,非 JSON 响应保留状态码,会话过期带回跳送回登录页,连不上时显示横幅
This commit is contained in:
20 files changed
+635
-141
No files matched your search
+17
-6
@@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { RequireAdmin } from "@/components/RequireAdmin";
|
import { RequireAdmin } from "@/components/RequireAdmin";
|
||||||
import { RequireAuth } from "@/components/RequireAuth";
|
import { RequireAuth } from "@/components/RequireAuth";
|
||||||
import { RequireOwner } from "@/components/RequireOwner";
|
import { RequireOwner } from "@/components/RequireOwner";
|
||||||
|
import { ValidParam } from "@/components/ValidParam";
|
||||||
|
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
|
||||||
import { Login } from "@/pages/Login";
|
import { Login } from "@/pages/Login";
|
||||||
import { Setup } from "@/pages/Setup";
|
import { Setup } from "@/pages/Setup";
|
||||||
import { Dashboard } from "@/pages/Dashboard";
|
import { Dashboard } from "@/pages/Dashboard";
|
||||||
@@ -70,11 +72,18 @@ export default function App() {
|
|||||||
{/* User-Side — app-tier */}
|
{/* User-Side — app-tier */}
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="servers" element={<ServersPage />} />
|
<Route path="servers" element={<ServersPage />} />
|
||||||
<Route path="servers/:name" element={<ServerConsole />} />
|
{/* A :name that is not a server name (a crafted link carrying
|
||||||
<Route path="servers/:name/players" element={<ServerPlayers />} />
|
"/", "?" or "..") renders "not found" before any request. */}
|
||||||
<Route path="servers/:name/backups" element={<ServerBackups />} />
|
<Route
|
||||||
<Route path="servers/:name/files" element={<ServerFiles />} />
|
path="servers/:name"
|
||||||
<Route path="servers/:name/luckperms" element={<ServerLuckPerms />} />
|
element={<ValidParam param="name" pattern={SERVER_NAME_PARAM} />}
|
||||||
|
>
|
||||||
|
<Route index element={<ServerConsole />} />
|
||||||
|
<Route path="players" element={<ServerPlayers />} />
|
||||||
|
<Route path="backups" element={<ServerBackups />} />
|
||||||
|
<Route path="files" element={<ServerFiles />} />
|
||||||
|
<Route path="luckperms" element={<ServerLuckPerms />} />
|
||||||
|
</Route>
|
||||||
<Route path="submissions" element={<MySubmissionsPage />} />
|
<Route path="submissions" element={<MySubmissionsPage />} />
|
||||||
<Route path="account" element={<Account />} />
|
<Route path="account" element={<Account />} />
|
||||||
|
|
||||||
@@ -90,7 +99,9 @@ export default function App() {
|
|||||||
{/* Owner-gated: user management (one level above admin). */}
|
{/* Owner-gated: user management (one level above admin). */}
|
||||||
<Route element={<RequireOwner />}>
|
<Route element={<RequireOwner />}>
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="users/:id" element={<UserDetailPage />} />
|
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
||||||
|
<Route index element={<UserDetailPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Cat, Globe, Sun, Moon, LogOut } from "lucide-react";
|
import { Cat, Globe, Sun, Moon, LogOut, WifiOff } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier";
|
|||||||
import { visibleSections, type NavSection } from "@/lib/nav";
|
import { visibleSections, type NavSection } from "@/lib/nav";
|
||||||
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
import { api } from "@/lib/api";
|
import { api, CONNECTION_EVENT, isConnectionLost } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
||||||
@@ -137,6 +138,36 @@ function ThemeToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConnectionBanner shows while API calls get no response at all (api.ts
|
||||||
|
// CONNECTION_EVENT): the network is down, or Cloudflare Access sent the call to
|
||||||
|
// its login page. It clears on the next call that gets through (the pages'
|
||||||
|
// polling makes one soon); an expired Access sign-in only passes on a full page
|
||||||
|
// load, hence the reload button.
|
||||||
|
function ConnectionBanner() {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
const [lost, setLost] = useState(isConnectionLost);
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = (e: Event) => setLost(!(e as CustomEvent<{ ok: boolean }>).detail.ok);
|
||||||
|
window.addEventListener(CONNECTION_EVENT, onChange);
|
||||||
|
return () => window.removeEventListener(CONNECTION_EVENT, onChange);
|
||||||
|
}, []);
|
||||||
|
if (!lost) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-sm text-amber-800 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<WifiOff className="h-4 w-4 shrink-0" />
|
||||||
|
{t("connection_lost")}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
{t("reload_page")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { isAdmin, isOwner } = useTier();
|
const { isAdmin, isOwner } = useTier();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -190,6 +221,7 @@ export function AppShell() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
<div className="flex min-w-0 flex-1 flex-col h-full overflow-y-auto">
|
||||||
|
<ConnectionBanner />
|
||||||
<header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
|
<header className="flex h-14 items-center justify-between border-b border-border px-4 md:hidden">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Cat className="h-5 w-5 text-primary" />
|
<Cat className="h-5 w-5 text-primary" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
@@ -7,7 +7,9 @@ import { useTier } from "@/lib/tier";
|
|||||||
// and everything under it). It encodes the three-way verdict from the tier model:
|
// and everything under it). It encodes the three-way verdict from the tier model:
|
||||||
//
|
//
|
||||||
// loading → a full-screen spinner (never flash login during boot /me)
|
// loading → a full-screen spinner (never flash login during boot /me)
|
||||||
// unauthenticated → /login (a genuine 401: no/expired session)
|
// unauthenticated → /login (a genuine 401: no/expired session), carrying
|
||||||
|
// the page to return to (?next=) and, when a session
|
||||||
|
// ended under an open page, state.sessionEnded
|
||||||
// otherwise → render the app (<Outlet/>)
|
// otherwise → render the app (<Outlet/>)
|
||||||
//
|
//
|
||||||
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
|
// The "otherwise" branch deliberately includes the graded-Zero-Trust degraded case
|
||||||
@@ -15,8 +17,9 @@ import { useTier } from "@/lib/tier";
|
|||||||
// app still renders User-Side, exactly as before local auth existed. Only a true
|
// app still renders User-Side, exactly as before local auth existed. Only a true
|
||||||
// 401 bounces to /login. Every admin route remains independently server-guarded.
|
// 401 bounces to /login. Every admin route remains independently server-guarded.
|
||||||
export function RequireAuth() {
|
export function RequireAuth() {
|
||||||
const { loading, unauthenticated } = useTier();
|
const { loading, unauthenticated, sessionEnded } = useTier();
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -26,6 +29,10 @@ export function RequireAuth() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (unauthenticated) return <Navigate to="/login" replace />;
|
if (unauthenticated) {
|
||||||
|
const back = location.pathname + location.search;
|
||||||
|
const to = back === "/" ? "/login" : `/login?next=${encodeURIComponent(back)}`;
|
||||||
|
return <Navigate to={to} replace state={sessionEnded ? { sessionEnded: true } : undefined} />;
|
||||||
|
}
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon } from "lucide-react";
|
import { Loader2, AlertTriangle, Inbox, ShieldX, Construction, Moon, SearchX } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -71,6 +71,22 @@ export function NotAuthorized() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function NotFound() {
|
||||||
|
const { t } = useTranslation("common");
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
|
<SearchX className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{t("not_found_title")}</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("not_found_body")}</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/" className="text-sm font-medium text-primary hover:underline">
|
||||||
|
{t("back_to_dashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PendingBackend is an HONEST placeholder for a surface whose backend read does
|
* PendingBackend is an HONEST placeholder for a surface whose backend read does
|
||||||
* not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
|
* not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import { NotFound } from "@/components/States";
|
||||||
|
|
||||||
|
// ValidParam is a layout route that renders its children only when one URL
|
||||||
|
// parameter has the expected shape (lib/params.ts), and "not found" otherwise.
|
||||||
|
export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) {
|
||||||
|
const value = useParams()[param];
|
||||||
|
if (value === undefined || !pattern.test(value)) return <NotFound />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -46,5 +46,6 @@
|
|||||||
"setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.",
|
"setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.",
|
||||||
"setup_create_passkey": "Create passkey",
|
"setup_create_passkey": "Create passkey",
|
||||||
"setup_registering": "Registering…",
|
"setup_registering": "Registering…",
|
||||||
"setup_default_passkey_name": "Default passkey"
|
"setup_default_passkey_name": "Default passkey",
|
||||||
|
"session_ended_notice": "Your session expired or was revoked. Sign in again to go back to the page you were on."
|
||||||
}
|
}
|
||||||
@@ -26,5 +26,9 @@
|
|||||||
"crash_updated_body": "A new version of the panel was just released. Reload to open this page.",
|
"crash_updated_body": "A new version of the panel was just released. Reload to open this page.",
|
||||||
"crash_reload": "Reload",
|
"crash_reload": "Reload",
|
||||||
"crash_details": "Error details",
|
"crash_details": "Error details",
|
||||||
"crash_copy": "Copy error details"
|
"crash_copy": "Copy error details",
|
||||||
|
"not_found_title": "Page not found",
|
||||||
|
"not_found_body": "The name in the link is not valid, or the page does not exist.",
|
||||||
|
"connection_lost": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. This clears by itself once the network is back; an expired Access sign-in needs a page reload.",
|
||||||
|
"reload_page": "Reload page"
|
||||||
}
|
}
|
||||||
@@ -79,5 +79,9 @@
|
|||||||
"too_many_streams": "Too many live streams are open — close some pages and try again.",
|
"too_many_streams": "Too many live streams are open — close some pages and try again.",
|
||||||
"protected_admin": "That name belongs to a linked administrator and can't be reclaimed.",
|
"protected_admin": "That name belongs to a linked administrator and can't be reclaimed.",
|
||||||
"auth_unavailable": "The sign-in service isn't available right now.",
|
"auth_unavailable": "The sign-in service isn't available right now.",
|
||||||
"setup_token_invalid": "That setup token is invalid or expired."
|
"setup_token_invalid": "That setup token is invalid or expired.",
|
||||||
|
"network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
|
||||||
|
"upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
|
||||||
|
"bad_path_param": "The name in this link is not valid. Open it again from the list.",
|
||||||
|
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent."
|
||||||
}
|
}
|
||||||
@@ -46,5 +46,6 @@
|
|||||||
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。",
|
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。",
|
||||||
"setup_create_passkey": "创建通行密钥",
|
"setup_create_passkey": "创建通行密钥",
|
||||||
"setup_registering": "注册中…",
|
"setup_registering": "注册中…",
|
||||||
"setup_default_passkey_name": "默认通行密钥"
|
"setup_default_passkey_name": "默认通行密钥",
|
||||||
|
"session_ended_notice": "你的登录已过期或已被撤销,请重新登录。登录后会回到刚才的页面。"
|
||||||
}
|
}
|
||||||
@@ -26,5 +26,9 @@
|
|||||||
"crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。",
|
"crash_updated_body": "面板刚发布了新版本,这个页面需要重新加载才能打开。",
|
||||||
"crash_reload": "重新加载",
|
"crash_reload": "重新加载",
|
||||||
"crash_details": "错误信息",
|
"crash_details": "错误信息",
|
||||||
"crash_copy": "复制错误信息"
|
"crash_copy": "复制错误信息",
|
||||||
|
"not_found_title": "找不到这个页面",
|
||||||
|
"not_found_body": "链接里的名称不合法,或者这个页面不存在。",
|
||||||
|
"connection_lost": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。网络恢复后会自动消失;Access 过期需要刷新页面重新登录。",
|
||||||
|
"reload_page": "刷新页面"
|
||||||
}
|
}
|
||||||
@@ -79,5 +79,9 @@
|
|||||||
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
|
"too_many_streams": "同时打开的实时连接过多——请关闭一些页面后再试。",
|
||||||
"protected_admin": "该名字属于已绑定的管理员账户,不能被认领。",
|
"protected_admin": "该名字属于已绑定的管理员账户,不能被认领。",
|
||||||
"auth_unavailable": "登录服务当前不可用。",
|
"auth_unavailable": "登录服务当前不可用。",
|
||||||
"setup_token_invalid": "初始化令牌无效或已过期。"
|
"setup_token_invalid": "初始化令牌无效或已过期。",
|
||||||
|
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
|
||||||
|
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
|
||||||
|
"bad_path_param": "链接里的名称无效,请从列表重新打开。",
|
||||||
|
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。"
|
||||||
}
|
}
|
||||||
+140
-1
@@ -12,7 +12,8 @@ vi.mock("./config", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Imported after the mock so api.ts picks up the mocked loadConfig.
|
// Imported after the mock so api.ts picks up the mocked loadConfig.
|
||||||
const { api, SETUP_REQUIRED_EVENT } = await import("./api");
|
const { api, SETUP_REQUIRED_EVENT, SESSION_EXPIRED_EVENT, CONNECTION_EVENT, humanizeError, isConnectionLost } =
|
||||||
|
await import("./api");
|
||||||
|
|
||||||
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
function fakeFetch(body: unknown, init?: { ok?: boolean; status?: number }) {
|
||||||
return vi.fn(async () => ({
|
return vi.fn(async () => ({
|
||||||
@@ -784,3 +785,141 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("path parameters", () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function lastURL(spy: typeof fetch): string {
|
||||||
|
const calls = (spy as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
return String(calls[calls.length - 1][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('encodes "/", "?" and "#" inside one segment', async () => {
|
||||||
|
const spy = fakeFetch({ player: "x", groups: [], permissions: [], output: "" });
|
||||||
|
vi.stubGlobal("fetch", spy);
|
||||||
|
await api.accessLuckPermsInfo("lobby", "a/b?c#d");
|
||||||
|
expect(lastURL(spy)).toBe("/servers/lobby/access/luckperms/a%2Fb%3Fc%23d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a decoded router param that climbs the path inside its segment", async () => {
|
||||||
|
// react-router decodes "..%2F..%2Fusers%2Fu1%3F" to this before a page sees it.
|
||||||
|
const spy = fakeFetch({ name: "x", desiredState: "Stopped" });
|
||||||
|
vi.stubGlobal("fetch", spy);
|
||||||
|
await api.stop("../../users/u1?");
|
||||||
|
expect(lastURL(spy)).toBe("/servers/..%2F..%2Fusers%2Fu1%3F/stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses dot segments and empty ones before sending anything", async () => {
|
||||||
|
const spy = fakeFetch({});
|
||||||
|
vi.stubGlobal("fetch", spy);
|
||||||
|
for (const bad of ["..", ".", ""]) {
|
||||||
|
await expect(api.stop(bad)).rejects.toMatchObject({ status: 0, code: "bad_path_param" });
|
||||||
|
await expect(api.deleteUser(bad)).rejects.toMatchObject({ code: "bad_path_param" });
|
||||||
|
}
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
expect(humanizeError({ status: 0, code: "bad_path_param" })).toMatch(/not valid/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not encode a value twice", async () => {
|
||||||
|
const spy = fakeFetch({ ok: true });
|
||||||
|
vi.stubGlobal("fetch", spy);
|
||||||
|
await api.revokeUserSession("u1", "ab/cd");
|
||||||
|
expect(lastURL(spy)).toBe("/users/u1/sessions/ab%2Fcd");
|
||||||
|
await api.listServerFiles("lobby", "world/level.dat");
|
||||||
|
expect(lastURL(spy)).toBe("/servers/lobby/files?path=world%2Flevel.dat");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("responses that are not the API's JSON", () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function htmlFetch(status: number, statusText: string) {
|
||||||
|
return vi.fn(async () => ({
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
text: async () => "<!DOCTYPE html><html><body>Bad gateway</body></html>",
|
||||||
|
})) as unknown as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("turns an HTML 502 page into upstream_unavailable with the status kept", async () => {
|
||||||
|
vi.stubGlobal("fetch", htmlFetch(502, "Bad Gateway"));
|
||||||
|
const err = await api.status("lobby").catch((e) => e);
|
||||||
|
expect(err).toMatchObject({ status: 502, code: "upstream_unavailable" });
|
||||||
|
expect(humanizeError(err)).toMatch(/unavailable/i);
|
||||||
|
expect(humanizeError(err)).not.toMatch(/JSON|DOCTYPE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the status of an HTML 401 so the session branch still runs", async () => {
|
||||||
|
const target = new EventTarget();
|
||||||
|
vi.stubGlobal("window", target);
|
||||||
|
let expired = 0;
|
||||||
|
target.addEventListener(SESSION_EXPIRED_EVENT, () => (expired += 1));
|
||||||
|
vi.stubGlobal("fetch", htmlFetch(401, "Unauthorized"));
|
||||||
|
const err = await api.status("lobby").catch((e) => e);
|
||||||
|
expect(err).toMatchObject({ status: 401 });
|
||||||
|
expect(humanizeError(err)).toMatch(/session/i);
|
||||||
|
expect(expired).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a 200 whose body is not JSON", async () => {
|
||||||
|
vi.stubGlobal("fetch", htmlFetch(200, "OK"));
|
||||||
|
await expect(api.me()).rejects.toMatchObject({ status: 200, code: "upstream_unavailable" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unknown 5xx code to the unavailable line", () => {
|
||||||
|
expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i);
|
||||||
|
expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session and connection signals", () => {
|
||||||
|
beforeEach(() => vi.restoreAllMocks());
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
function listen(name: string) {
|
||||||
|
const target = new EventTarget();
|
||||||
|
vi.stubGlobal("window", target);
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
target.addEventListener(name, (e) => seen.push((e as CustomEvent).detail ?? true));
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("announces a 401 from a protected route", async () => {
|
||||||
|
const seen = listen(SESSION_EXPIRED_EVENT);
|
||||||
|
vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
|
||||||
|
await expect(api.myServers()).rejects.toMatchObject({ status: 401 });
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays silent for /me and the sign-in doors", async () => {
|
||||||
|
const seen = listen(SESSION_EXPIRED_EVENT);
|
||||||
|
vi.stubGlobal("fetch", fakeFetch({ error: { code: "unauthorized", message: "x" } }, { ok: false, status: 401 }));
|
||||||
|
await expect(api.me()).rejects.toMatchObject({ status: 401 });
|
||||||
|
await expect(api.setupStatus()).rejects.toMatchObject({ status: 401 });
|
||||||
|
expect(seen).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a fetch that got no response, and the next one that did", async () => {
|
||||||
|
const seen = listen(CONNECTION_EVENT);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const err = await api.myServers().catch((e) => e);
|
||||||
|
expect(err).toMatchObject({ status: 0, code: "network_error" });
|
||||||
|
expect(humanizeError(err)).toMatch(/Cloudflare Access/);
|
||||||
|
await api.myServers().catch(() => {});
|
||||||
|
expect(seen).toEqual([{ ok: false }]);
|
||||||
|
expect(isConnectionLost()).toBe(true);
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", fakeFetch({ servers: [] }));
|
||||||
|
await api.myServers();
|
||||||
|
expect(seen).toEqual([{ ok: false }, { ok: true }]);
|
||||||
|
expect(isConnectionLost()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+206
-108
@@ -38,12 +38,9 @@ import i18next from "i18next";
|
|||||||
// service token, and the RCON password is never requested (spec §8).
|
// service token, and the RCON password is never requested (spec §8).
|
||||||
|
|
||||||
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
|
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
|
||||||
return (
|
if (typeof x !== "object" || x === null || !("error" in x)) return false;
|
||||||
typeof x === "object" &&
|
const e = (x as { error: unknown }).error;
|
||||||
x !== null &&
|
return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string";
|
||||||
"error" in x &&
|
|
||||||
typeof (x as { error: unknown }).error === "object"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A locked session — one that still owes the forced onboarding (passkey
|
// A locked session — one that still owes the forced onboarding (passkey
|
||||||
@@ -61,57 +58,167 @@ function announceSetupRequired(err: ApiError): void {
|
|||||||
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
|
window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
// A 401 from a protected route means the session ended under the page (it
|
||||||
|
// expired, or an admin revoked it). TierProvider hears this and re-reads /me,
|
||||||
|
// and RequireAuth then sends the person to /login with the page to come back
|
||||||
|
// to. /me is left out because it is that re-read, and /auth/* because the
|
||||||
|
// sign-in doors run without a session by design.
|
||||||
|
export const SESSION_EXPIRED_EVENT = "felis:session-expired";
|
||||||
|
|
||||||
|
function announceSessionExpired(err: ApiError, path: string): void {
|
||||||
|
if (err.status !== 401 || typeof window === "undefined") return;
|
||||||
|
if (path === "/me" || path.startsWith("/auth/")) return;
|
||||||
|
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONNECTION_EVENT reports when requests stop reaching the API (detail.ok =
|
||||||
|
// false) and when one gets through again (true). A fetch that rejects never
|
||||||
|
// saw a response: the network is down, or Cloudflare Access redirected the
|
||||||
|
// call to its cross-origin login page because the Access session expired,
|
||||||
|
// which fetch reports as the same TypeError. AppShell shows a banner with a
|
||||||
|
// reload, since only a full page load can go through the Access login.
|
||||||
|
export const CONNECTION_EVENT = "felis:connection";
|
||||||
|
let connectionLost = false;
|
||||||
|
|
||||||
|
/** isConnectionLost reports whether the last call got no response. */
|
||||||
|
export function isConnectionLost(): boolean {
|
||||||
|
return connectionLost;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportConnection(ok: boolean): void {
|
||||||
|
if (connectionLost === !ok) return;
|
||||||
|
connectionLost = !ok;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(new CustomEvent(CONNECTION_EVENT, { detail: { ok } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function networkError(e: unknown): ApiError {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
code: "network_error",
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlPath builds an API path from literal text and route values, percent-
|
||||||
|
// encoding each value as one path segment. Values come from router params and
|
||||||
|
// form fields, and react-router hands params over decoded, so "%2F" arrives as
|
||||||
|
// "/": interpolated raw, a crafted link could point a button at another
|
||||||
|
// endpoint, carrying the viewer's cookie. encodeURIComponent covers "/", "?"
|
||||||
|
// and "#"; "." and ".." survive it and fetch would still walk the path up, so
|
||||||
|
// those and "" are refused before anything is sent.
|
||||||
|
export function urlPath(strings: TemplateStringsArray, ...values: string[]): string {
|
||||||
|
let out = strings[0];
|
||||||
|
values.forEach((v, i) => {
|
||||||
|
const seg = String(v);
|
||||||
|
if (seg === "" || seg === "." || seg === "..") {
|
||||||
|
const err: ApiError = {
|
||||||
|
status: 0,
|
||||||
|
code: "bad_path_param",
|
||||||
|
message: `refusing ${JSON.stringify(seg)} as a path segment`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
out += encodeURIComponent(seg) + strings[i + 1];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchOK performs one call and returns the response when it is 2xx, else
|
||||||
|
// throws an ApiError that always keeps the HTTP status. A body that is not the
|
||||||
|
// API's JSON envelope (an HTML 502 or 524 page from the tunnel while the API
|
||||||
|
// restarts, a bare 503 from the ingress) becomes `upstream_unavailable`, so the
|
||||||
|
// setup and session branches still see the status and the person reads "try
|
||||||
|
// again shortly" instead of a JSON parse error.
|
||||||
|
async function fetchOK(path: string, init: RequestInit): Promise<Response> {
|
||||||
const { apiBase } = await loadConfig();
|
const { apiBase } = await loadConfig();
|
||||||
const res = await fetch(`${apiBase}${path}`, {
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${apiBase}${path}`, { ...init, credentials: "include" });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === "AbortError") throw e;
|
||||||
|
reportConnection(false);
|
||||||
|
throw networkError(e);
|
||||||
|
}
|
||||||
|
reportConnection(true);
|
||||||
|
if (res.ok) return res;
|
||||||
|
|
||||||
|
let parsed: unknown = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(await res.text());
|
||||||
|
} catch {
|
||||||
|
/* no body, or not JSON: an ingress or tunnel answered */
|
||||||
|
}
|
||||||
|
const err: ApiError = isApiError(parsed)
|
||||||
|
? { status: res.status, code: parsed.error.code, message: parsed.error.message }
|
||||||
|
: {
|
||||||
|
status: res.status,
|
||||||
|
code: res.status >= 500 ? "upstream_unavailable" : "error",
|
||||||
|
message: res.statusText,
|
||||||
|
};
|
||||||
|
announceSetupRequired(err);
|
||||||
|
announceSessionExpired(err, path);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// send returns a 2xx response's parsed JSON body (null for an empty one).
|
||||||
|
async function send<T>(path: string, init: RequestInit): Promise<T> {
|
||||||
|
const res = await fetchOK(path, init);
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = await res.text();
|
||||||
|
} catch (e) {
|
||||||
|
reportConnection(false);
|
||||||
|
throw networkError(e);
|
||||||
|
}
|
||||||
|
if (!text) return null as T;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
const err: ApiError = {
|
||||||
|
status: res.status,
|
||||||
|
code: "upstream_unavailable",
|
||||||
|
message: "the response was not JSON",
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
return send<T>(path, {
|
||||||
method,
|
method,
|
||||||
credentials: "include",
|
|
||||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await res.text();
|
|
||||||
const parsed: unknown = text ? JSON.parse(text) : null;
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err: ApiError = {
|
|
||||||
status: res.status,
|
|
||||||
code: isApiError(parsed) ? parsed.error.code : "error",
|
|
||||||
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
|
||||||
};
|
|
||||||
announceSetupRequired(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return parsed as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestRaw<T>(
|
function requestRaw<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body: Blob,
|
body: Blob,
|
||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { apiBase } = await loadConfig();
|
return send<T>(path, { method, headers, body });
|
||||||
const res = await fetch(`${apiBase}${path}`, {
|
}
|
||||||
method,
|
|
||||||
credentials: "include",
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = await res.text();
|
// rejectingSync turns a synchronous throw inside an api method (urlPath refusing
|
||||||
const parsed: unknown = text ? JSON.parse(text) : null;
|
// a segment) into a rejected promise, so every caller handles it the way it
|
||||||
|
// handles any failed call.
|
||||||
if (!res.ok) {
|
function rejectingSync<T extends Record<string, unknown>>(methods: T): T {
|
||||||
const err: ApiError = {
|
const out: Record<string, unknown> = {};
|
||||||
status: res.status,
|
for (const [key, fn] of Object.entries(methods)) {
|
||||||
code: isApiError(parsed) ? parsed.error.code : "error",
|
out[key] =
|
||||||
message: isApiError(parsed) ? parsed.error.message : res.statusText,
|
typeof fn === "function"
|
||||||
};
|
? (...args: unknown[]) => {
|
||||||
announceSetupRequired(err);
|
try {
|
||||||
throw err;
|
return fn(...args);
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: fn;
|
||||||
}
|
}
|
||||||
return parsed as T;
|
return out as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
|
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
|
||||||
@@ -127,7 +234,7 @@ export interface SetupState {
|
|||||||
setup_required: boolean;
|
setup_required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = rejectingSync({
|
||||||
// Session doors (spec §B). The product is passwordless: a session is minted only
|
// Session doors (spec §B). The product is passwordless: a session is minted only
|
||||||
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
|
// by passkey, email-OTP, bind code, or the op-login vouch flow below. Every door
|
||||||
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
|
// sets an HttpOnly cookie as a side effect and may 403 `local_auth_disabled` on a
|
||||||
@@ -168,7 +275,7 @@ export const api = {
|
|||||||
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
|
request<{ request_id: string; expires_at: string }>("POST", "/auth/op-login/start", { email }),
|
||||||
|
|
||||||
opLoginStatus: (id: string) =>
|
opLoginStatus: (id: string) =>
|
||||||
request<{ approved: boolean }>("GET", `/auth/op-login/status/${encodeURIComponent(id)}`),
|
request<{ approved: boolean }>("GET", urlPath`/auth/op-login/status/${id}`),
|
||||||
|
|
||||||
opLoginFinish: (request_id: string, code: string) =>
|
opLoginFinish: (request_id: string, code: string) =>
|
||||||
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
|
request<{ user_id: string; role: string }>("POST", "/auth/op-login/finish", {
|
||||||
@@ -201,23 +308,23 @@ export const api = {
|
|||||||
fleet: () =>
|
fleet: () =>
|
||||||
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
|
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
|
||||||
|
|
||||||
status: (name: string) => request<ServerStatus>("GET", `/servers/${name}/status`),
|
status: (name: string) => request<ServerStatus>("GET", urlPath`/servers/${name}/status`),
|
||||||
|
|
||||||
wake: (name: string) =>
|
wake: (name: string) =>
|
||||||
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/wake`),
|
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
|
||||||
|
|
||||||
stop: (name: string) =>
|
stop: (name: string) =>
|
||||||
request<{ name: string; desiredState: string }>("POST", `/servers/${name}/stop`),
|
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`),
|
||||||
|
|
||||||
claim: (name: string) =>
|
claim: (name: string) =>
|
||||||
request<{ name: string; claimed: boolean }>("POST", `/servers/${name}/claim`),
|
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
|
||||||
|
|
||||||
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
|
/** sendCommand runs one RCON command against a running server (spec §8 写=RCON).
|
||||||
* The backend strips a leading "/", rejects control characters (newline → 400)
|
* The backend strips a leading "/", rejects control characters (newline → 400)
|
||||||
* and caps the command at 1000 bytes. The reply is the server's plain-text
|
* and caps the command at 1000 bytes. The reply is the server's plain-text
|
||||||
* response body. */
|
* response body. */
|
||||||
sendCommand: (name: string, command: string) =>
|
sendCommand: (name: string, command: string) =>
|
||||||
request<{ output: string }>("POST", `/servers/${name}/command`, { command }),
|
request<{ output: string }>("POST", urlPath`/servers/${name}/command`, { command }),
|
||||||
|
|
||||||
// Access control (spec §7 access). The backend translates these STRUCTURED fields
|
// Access control (spec §7 access). The backend translates these STRUCTURED fields
|
||||||
// into RCON commands — every field is charset-validated server-side before it is
|
// into RCON commands — every field is charset-validated server-side before it is
|
||||||
@@ -230,10 +337,10 @@ export const api = {
|
|||||||
* Running server (the readiness gate covers the read, not just the writes), so
|
* Running server (the readiness gate covers the read, not just the writes), so
|
||||||
* callers must gate the fetch on phase === "Running". */
|
* callers must gate the fetch on phase === "Running". */
|
||||||
accessWhitelistList: (name: string) =>
|
accessWhitelistList: (name: string) =>
|
||||||
request<WhitelistResult>("GET", `/servers/${name}/access/whitelist`),
|
request<WhitelistResult>("GET", urlPath`/servers/${name}/access/whitelist`),
|
||||||
|
|
||||||
accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
|
accessWhitelist: (name: string, action: "add" | "remove", player: string) =>
|
||||||
request<AccessResult>("POST", `/servers/${name}/access/whitelist`, {
|
request<AccessResult>("POST", urlPath`/servers/${name}/access/whitelist`, {
|
||||||
action,
|
action,
|
||||||
player,
|
player,
|
||||||
}),
|
}),
|
||||||
@@ -242,10 +349,10 @@ export const api = {
|
|||||||
* requires a Running server (the readiness gate covers the read too), so callers
|
* requires a Running server (the readiness gate covers the read too), so callers
|
||||||
* gate the fetch on phase === "Running". */
|
* gate the fetch on phase === "Running". */
|
||||||
accessBanList: (name: string) =>
|
accessBanList: (name: string) =>
|
||||||
request<BanlistResult>("GET", `/servers/${name}/access/ban`),
|
request<BanlistResult>("GET", urlPath`/servers/${name}/access/ban`),
|
||||||
|
|
||||||
accessBan: (name: string, action: "ban" | "pardon", player: string) =>
|
accessBan: (name: string, action: "ban" | "pardon", player: string) =>
|
||||||
request<AccessResult>("POST", `/servers/${name}/access/ban`, {
|
request<AccessResult>("POST", urlPath`/servers/${name}/access/ban`, {
|
||||||
action,
|
action,
|
||||||
player,
|
player,
|
||||||
}),
|
}),
|
||||||
@@ -254,10 +361,10 @@ export const api = {
|
|||||||
* the count alone). Like accessWhitelistList this GET requires a Running server,
|
* the count alone). Like accessWhitelistList this GET requires a Running server,
|
||||||
* so callers gate the fetch on phase === "Running". */
|
* so callers gate the fetch on phase === "Running". */
|
||||||
accessPlayers: (name: string) =>
|
accessPlayers: (name: string) =>
|
||||||
request<PlayersResult>("GET", `/servers/${name}/access/players`),
|
request<PlayersResult>("GET", urlPath`/servers/${name}/access/players`),
|
||||||
|
|
||||||
accessKick: (name: string, player: string) =>
|
accessKick: (name: string, player: string) =>
|
||||||
request<KickResult>("POST", `/servers/${name}/access/kick`, { player }),
|
request<KickResult>("POST", urlPath`/servers/${name}/access/kick`, { player }),
|
||||||
|
|
||||||
accessLuckPermsInfo: (name: string, player: string) =>
|
accessLuckPermsInfo: (name: string, player: string) =>
|
||||||
request<{
|
request<{
|
||||||
@@ -265,7 +372,7 @@ export const api = {
|
|||||||
groups: string[];
|
groups: string[];
|
||||||
permissions: { node: string; value: boolean; world?: string }[];
|
permissions: { node: string; value: boolean; world?: string }[];
|
||||||
output: string;
|
output: string;
|
||||||
}>("GET", `/servers/${name}/access/luckperms/${player}`),
|
}>("GET", urlPath`/servers/${name}/access/luckperms/${player}`),
|
||||||
|
|
||||||
accessPermission: (
|
accessPermission: (
|
||||||
name: string,
|
name: string,
|
||||||
@@ -277,7 +384,7 @@ export const api = {
|
|||||||
) =>
|
) =>
|
||||||
request<AccessResult & { node: string; value?: boolean; world?: string }>(
|
request<AccessResult & { node: string; value?: boolean; world?: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
`/servers/${name}/access/permission`,
|
urlPath`/servers/${name}/access/permission`,
|
||||||
{ action, player, node, value, world }
|
{ action, player, node, value, world }
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -289,7 +396,7 @@ export const api = {
|
|||||||
) =>
|
) =>
|
||||||
request<AccessResult & { group: string }>(
|
request<AccessResult & { group: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
`/servers/${name}/access/group`,
|
urlPath`/servers/${name}/access/group`,
|
||||||
{ action, player, group }
|
{ action, player, group }
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -306,10 +413,10 @@ export const api = {
|
|||||||
request<Build>("POST", "/images/build", req),
|
request<Build>("POST", "/images/build", req),
|
||||||
|
|
||||||
getBuild: (id: string) =>
|
getBuild: (id: string) =>
|
||||||
request<Build>("GET", `/images/build/${id}`),
|
request<Build>("GET", urlPath`/images/build/${id}`),
|
||||||
|
|
||||||
cancelBuild: (id: string) =>
|
cancelBuild: (id: string) =>
|
||||||
request<Build>("POST", `/images/build/${id}/cancel`),
|
request<Build>("POST", urlPath`/images/build/${id}/cancel`),
|
||||||
|
|
||||||
createServer: (req: CreateServerRequest) =>
|
createServer: (req: CreateServerRequest) =>
|
||||||
request<{ name: string; subdomain: string; desiredState: string }>(
|
request<{ name: string; subdomain: string; desiredState: string }>(
|
||||||
@@ -337,7 +444,7 @@ export const api = {
|
|||||||
}) =>
|
}) =>
|
||||||
request<{ name: string; desiredState: string }>(
|
request<{ name: string; desiredState: string }>(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
`/servers/${name}`,
|
urlPath`/servers/${name}`,
|
||||||
req,
|
req,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -368,7 +475,7 @@ export const api = {
|
|||||||
if (!safetySnapshot) body.safety_snapshot = false;
|
if (!safetySnapshot) body.safety_snapshot = false;
|
||||||
return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>(
|
return request<{ name: string; status: string; backup_id: string; safety_snapshot?: boolean }>(
|
||||||
"POST",
|
"POST",
|
||||||
`/servers/${name}/restore-backup`,
|
urlPath`/servers/${name}/restore-backup`,
|
||||||
Object.keys(body).length ? body : undefined,
|
Object.keys(body).length ? body : undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -380,7 +487,7 @@ export const api = {
|
|||||||
// {name, status:"backing_up"}: the Job is enqueued, not done — watch
|
// {name, status:"backing_up"}: the Job is enqueued, not done — watch
|
||||||
// serverJobs for the outcome.
|
// serverJobs for the outcome.
|
||||||
backupNow: (name: string) =>
|
backupNow: (name: string) =>
|
||||||
request<{ name: string; status: string }>("POST", `/servers/${name}/backup`),
|
request<{ name: string; status: string }>("POST", urlPath`/servers/${name}/backup`),
|
||||||
|
|
||||||
// serverJobs lists the newest backup/restore Jobs of one server, newest first
|
// serverJobs lists the newest backup/restore Jobs of one server, newest first
|
||||||
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
|
// (GET /servers/{name}/jobs). Owner-or-admin gated server-side; a Job's
|
||||||
@@ -389,7 +496,7 @@ export const api = {
|
|||||||
serverJobs: (name: string) =>
|
serverJobs: (name: string) =>
|
||||||
request<{ server: string; jobs: ServerJob[] }>(
|
request<{ server: string; jobs: ServerJob[] }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/servers/${name}/jobs`,
|
urlPath`/servers/${name}/jobs`,
|
||||||
).then((r) => r.jobs ?? []),
|
).then((r) => r.jobs ?? []),
|
||||||
|
|
||||||
// Server file editor (spec §7). All three routes are owner-or-admin gated and
|
// Server file editor (spec §7). All three routes are owner-or-admin gated and
|
||||||
@@ -401,7 +508,7 @@ export const api = {
|
|||||||
listServerFiles: (name: string, path: string) =>
|
listServerFiles: (name: string, path: string) =>
|
||||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
|
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/servers/${name}/files?path=${encodeURIComponent(path)}`,
|
urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
// readServerFile returns one file's bytes (base64) and the sha256 of the file
|
// readServerFile returns one file's bytes (base64) and the sha256 of the file
|
||||||
@@ -410,7 +517,7 @@ export const api = {
|
|||||||
readServerFile: (name: string, path: string) =>
|
readServerFile: (name: string, path: string) =>
|
||||||
request<{ path: string; content: string; sha256: string }>(
|
request<{ path: string; content: string; sha256: string }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
// writeServerFile atomically replaces a file's contents (creating it if
|
// writeServerFile atomically replaces a file's contents (creating it if
|
||||||
@@ -421,7 +528,7 @@ export const api = {
|
|||||||
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
|
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
|
||||||
request<{ path: string; status: string; sha256: string }>(
|
request<{ path: string; status: string; sha256: string }>(
|
||||||
"PUT",
|
"PUT",
|
||||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
|
||||||
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
|
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -456,7 +563,7 @@ export const api = {
|
|||||||
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
|
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
|
||||||
|
|
||||||
passkeyDelete: (id: string) =>
|
passkeyDelete: (id: string) =>
|
||||||
request<void>("DELETE", `/account/passkey/credentials/${id}`),
|
request<void>("DELETE", urlPath`/account/passkey/credentials/${id}`),
|
||||||
|
|
||||||
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
|
// Account migration (spec §B3 inherit). Started in-game with /felis migrate; the
|
||||||
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
|
// web side then drives: status → step-up confirm (passkey when enrolled, email-OTP
|
||||||
@@ -504,14 +611,14 @@ export const api = {
|
|||||||
// expectedDigest is the sha256 of the context the reviewer looked at; the API
|
// expectedDigest is the sha256 of the context the reviewer looked at; the API
|
||||||
// refuses the approval (409 context_changed) when the upload has since changed.
|
// refuses the approval (409 context_changed) when the upload has since changed.
|
||||||
approveSubmission: (id: string, expectedDigest: string) =>
|
approveSubmission: (id: string, expectedDigest: string) =>
|
||||||
request<Submission>("POST", `/submissions/${id}/approve`, { expected_digest: expectedDigest }),
|
request<Submission>("POST", urlPath`/submissions/${id}/approve`, { expected_digest: expectedDigest }),
|
||||||
|
|
||||||
rejectSubmission: (id: string, reason: string) =>
|
rejectSubmission: (id: string, reason: string) =>
|
||||||
request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
|
request<Submission>("POST", urlPath`/submissions/${id}/reject`, { reason }),
|
||||||
|
|
||||||
// Retire a submission outright (row + uploaded context) — the review queue's
|
// Retire a submission outright (row + uploaded context) — the review queue's
|
||||||
// lifecycle valve, the only way an upload is reclaimed from the PVC.
|
// lifecycle valve, the only way an upload is reclaimed from the PVC.
|
||||||
deleteSubmission: (id: string) => request<Submission>("DELETE", `/submissions/${id}`),
|
deleteSubmission: (id: string) => request<Submission>("DELETE", urlPath`/submissions/${id}`),
|
||||||
|
|
||||||
// The reviewer's read path to the uploaded build context: the executed
|
// The reviewer's read path to the uploaded build context: the executed
|
||||||
// Dockerfile lives inside the tarball, so approving without this would be
|
// Dockerfile lives inside the tarball, so approving without this would be
|
||||||
@@ -520,27 +627,7 @@ export const api = {
|
|||||||
// Resolves to the sha256 the API vouched for while streaming these bytes (it
|
// Resolves to the sha256 the API vouched for while streaming these bytes (it
|
||||||
// aborts the transfer on a mismatch), so the approval can name what was read.
|
// aborts the transfer on a mismatch), so the approval can name what was read.
|
||||||
downloadSubmissionContext: async (id: string): Promise<string | null> => {
|
downloadSubmissionContext: async (id: string): Promise<string | null> => {
|
||||||
const { apiBase } = await loadConfig();
|
const res = await fetchOK(urlPath`/submissions/${id}/context`, { method: "GET" });
|
||||||
const res = await fetch(`${apiBase}/submissions/${id}/context`, {
|
|
||||||
method: "GET",
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let code = "error";
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(await res.text()) as unknown;
|
|
||||||
if (isApiError(parsed)) {
|
|
||||||
code = parsed.error.code;
|
|
||||||
message = parsed.error.message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* non-JSON error body (e.g. an ingress page): keep the status line */
|
|
||||||
}
|
|
||||||
const err: ApiError = { status: res.status, code, message };
|
|
||||||
announceSetupRequired(err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
|
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -559,13 +646,13 @@ export const api = {
|
|||||||
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
||||||
|
|
||||||
uploadSubmissionContext: (id: string, file: Blob) =>
|
uploadSubmissionContext: (id: string, file: Blob) =>
|
||||||
requestRaw<Submission>("POST", `/me/submissions/${id}/context`, file, {
|
requestRaw<Submission>("POST", urlPath`/me/submissions/${id}/context`, file, {
|
||||||
"Content-Type": "application/x-gzip",
|
"Content-Type": "application/x-gzip",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Retract the caller's own pending submission (and its uploaded context), which
|
// Retract the caller's own pending submission (and its uploaded context), which
|
||||||
// frees their pending slot and storage budget. Reviewed submissions are frozen.
|
// frees their pending slot and storage budget. Reviewed submissions are frozen.
|
||||||
withdrawSubmission: (id: string) => request<Submission>("DELETE", `/me/submissions/${id}`),
|
withdrawSubmission: (id: string) => request<Submission>("DELETE", urlPath`/me/submissions/${id}`),
|
||||||
|
|
||||||
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
getUpdateWindow: () => request<UpdateWindow>("GET", "/updates/window"),
|
||||||
|
|
||||||
@@ -596,54 +683,54 @@ export const api = {
|
|||||||
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
|
).then((r) => ({ users: r.users ?? [], total: r.total ?? 0 }));
|
||||||
},
|
},
|
||||||
|
|
||||||
getUser: (id: string) => request<UserDetail>("GET", `/users/${id}`),
|
getUser: (id: string) => request<UserDetail>("GET", urlPath`/users/${id}`),
|
||||||
|
|
||||||
createUser: (req: CreateUserRequest) =>
|
createUser: (req: CreateUserRequest) =>
|
||||||
request<UserView>("POST", "/users", req),
|
request<UserView>("POST", "/users", req),
|
||||||
|
|
||||||
patchUser: (id: string, patch: PatchUserRequest) =>
|
patchUser: (id: string, patch: PatchUserRequest) =>
|
||||||
request<UserView>("PATCH", `/users/${id}`, patch),
|
request<UserView>("PATCH", urlPath`/users/${id}`, patch),
|
||||||
|
|
||||||
deleteUser: (id: string) =>
|
deleteUser: (id: string) =>
|
||||||
request<{ deleted: boolean }>("DELETE", `/users/${id}`),
|
request<{ deleted: boolean }>("DELETE", urlPath`/users/${id}`),
|
||||||
|
|
||||||
disableUser: (id: string, disabled: boolean) =>
|
disableUser: (id: string, disabled: boolean) =>
|
||||||
request<{ id: string; disabled: boolean }>("POST", `/users/${id}/disable`, { disabled }),
|
request<{ id: string; disabled: boolean }>("POST", urlPath`/users/${id}/disable`, { disabled }),
|
||||||
|
|
||||||
getUserQuotas: (id: string) => request<QuotaView>("GET", `/users/${id}/quotas`),
|
getUserQuotas: (id: string) => request<QuotaView>("GET", urlPath`/users/${id}/quotas`),
|
||||||
|
|
||||||
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
setUserQuotas: (id: string, quotas: QuotaInput) =>
|
||||||
request<QuotaView>("PUT", `/users/${id}/quotas`, quotas),
|
request<QuotaView>("PUT", urlPath`/users/${id}/quotas`, quotas),
|
||||||
|
|
||||||
listUserSessions: (id: string) =>
|
listUserSessions: (id: string) =>
|
||||||
request<{ sessions: SessionView[] }>("GET", `/users/${id}/sessions`).then((r) => r.sessions ?? []),
|
request<{ sessions: SessionView[] }>("GET", urlPath`/users/${id}/sessions`).then((r) => r.sessions ?? []),
|
||||||
|
|
||||||
revokeUserSessions: (id: string) =>
|
revokeUserSessions: (id: string) =>
|
||||||
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions`),
|
request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions`),
|
||||||
|
|
||||||
revokeUserSession: (id: string, hash: string) =>
|
revokeUserSession: (id: string, hash: string) =>
|
||||||
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
|
request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/sessions/${hash}`),
|
||||||
|
|
||||||
// unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account
|
// unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account
|
||||||
// remediation for a lost or compromised authenticator). It is deliberately not
|
// remediation for a lost or compromised authenticator). It is deliberately not
|
||||||
// a lockout — the account keeps its other doors (email OTP, in-game op-login
|
// a lockout — the account keeps its other doors (email OTP, in-game op-login
|
||||||
// re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op.
|
// re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op.
|
||||||
unbindUserPasskeys: (id: string) =>
|
unbindUserPasskeys: (id: string) =>
|
||||||
request<{ ok: boolean }>("DELETE", `/users/${id}/passkeys`),
|
request<{ ok: boolean }>("DELETE", urlPath`/users/${id}/passkeys`),
|
||||||
|
|
||||||
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
||||||
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
`/users/${id}/links`,
|
urlPath`/users/${id}/links`,
|
||||||
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
|
{ mc_uuid: mcUuid, auth_source: authSource ?? "mojang" },
|
||||||
),
|
),
|
||||||
|
|
||||||
unlinkAccount: (id: string, mcUuid: string) =>
|
unlinkAccount: (id: string, mcUuid: string) =>
|
||||||
request<{ ok: boolean; mc_uuid: string }>(
|
request<{ ok: boolean; mc_uuid: string }>(
|
||||||
"DELETE",
|
"DELETE",
|
||||||
`/users/${id}/links/${encodeURIComponent(mcUuid)}`,
|
urlPath`/users/${id}/links/${mcUuid}`,
|
||||||
),
|
),
|
||||||
};
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors
|
* consoleStreamURL builds the §8 read-side SSE endpoint for a server. It mirrors
|
||||||
@@ -867,9 +954,20 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("auth_unavailable");
|
return t("auth_unavailable");
|
||||||
case "setup_token_invalid":
|
case "setup_token_invalid":
|
||||||
return t("setup_token_invalid");
|
return t("setup_token_invalid");
|
||||||
|
// The request never got a response: the network is down, or Cloudflare
|
||||||
|
// Access sent it to its login page because the Access session expired.
|
||||||
|
case "network_error":
|
||||||
|
return t("network_error");
|
||||||
|
// A proxy or the tunnel answered for the API (it is restarting or down).
|
||||||
|
case "upstream_unavailable":
|
||||||
|
return t("upstream_unavailable");
|
||||||
|
case "bad_path_param":
|
||||||
|
return t("bad_path_param");
|
||||||
default:
|
default:
|
||||||
if (err.status === 401) return t("session_expired");
|
if (err.status === 401) return t("session_expired");
|
||||||
if (err.status === 403) return t("forbidden");
|
if (err.status === 403) return t("forbidden");
|
||||||
|
if (err.status === 413) return t("payload_too_large");
|
||||||
|
if (err.status !== undefined && err.status >= 500) return t("upstream_unavailable");
|
||||||
return err.message ?? t("generic");
|
return err.message ?? t("generic");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { deriveAuth, isUnauthorized } from "./auth";
|
import { deriveAuth, isUnauthorized, loginReturnPath } from "./auth";
|
||||||
import type { Identity } from "./types";
|
import type { Identity } from "./types";
|
||||||
|
|
||||||
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
|
// deriveAuth is the load-bearing auth decision: it decides who is bounced to /login
|
||||||
@@ -69,3 +69,16 @@ describe("deriveAuth", () => {
|
|||||||
expect(s.unauthenticated).toBe(false);
|
expect(s.unauthenticated).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("loginReturnPath", () => {
|
||||||
|
it("returns an in-app path as given", () => {
|
||||||
|
expect(loginReturnPath("/servers/lobby/files?x=1")).toBe("/servers/lobby/files?x=1");
|
||||||
|
expect(loginReturnPath("/admin/users/abc")).toBe("/admin/users/abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the dashboard for anything that could leave the site or loop", () => {
|
||||||
|
for (const next of [null, "", "servers", "https://evil.test", "//evil.test", "/\\evil.test", "/\t/evil.test", "/login", "/login?next=/x", "/setup"]) {
|
||||||
|
expect(loginReturnPath(next), JSON.stringify(next)).toBe("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,3 +50,16 @@ export function deriveAuth(
|
|||||||
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
unauthenticated: !loading && identity === null && isUnauthorized(error),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** loginReturnPath reads the ?next= a sign-in redirect carried and returns the
|
||||||
|
* in-app path to land on afterwards. Only a same-origin absolute path counts:
|
||||||
|
* "//host" is a protocol-relative URL, and a browser reads a backslash as a
|
||||||
|
* slash and drops tabs and newlines, so "/\host" or "/<tab>/host" would be one
|
||||||
|
* too; the sign-in pages themselves would loop. Each falls back to the
|
||||||
|
* dashboard. */
|
||||||
|
export function loginReturnPath(next: string | null): string {
|
||||||
|
if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
|
||||||
|
if (/[\\\u0000-\u001f]/.test(next)) return "/";
|
||||||
|
if (/^\/(login|setup)(\/|\?|$)/.test(next)) return "/";
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "./params";
|
||||||
|
|
||||||
|
describe("route param shapes", () => {
|
||||||
|
it("accepts real server names", () => {
|
||||||
|
for (const name of ["lobby", "login", "test-one", "a1", "x"]) {
|
||||||
|
expect(SERVER_NAME_PARAM.test(name), name).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses names a crafted link would carry", () => {
|
||||||
|
for (const name of ["..", ".", "", "a/b", "../users/x", "a?b", "a#b", "-a", "a-", "Lobby", "a b", "a".repeat(64)]) {
|
||||||
|
expect(SERVER_NAME_PARAM.test(name), JSON.stringify(name)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts hex and UUID user ids, refuses path characters", () => {
|
||||||
|
expect(USER_ID_PARAM.test("0123456789abcdef0123456789abcdef")).toBe(true);
|
||||||
|
expect(USER_ID_PARAM.test("3f2b8c1e-9a4d-4c7e-8b1a-2d3e4f5a6b7c")).toBe(true);
|
||||||
|
for (const id of ["..", ".", "", "a/b", "a?b", "a%2Fb", "a.b"]) {
|
||||||
|
expect(USER_ID_PARAM.test(id), JSON.stringify(id)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Shapes of the URL parameters routes accept. react-router hands a param over
|
||||||
|
// decoded ("%2F" becomes "/"), and pages build API paths from it, so a route
|
||||||
|
// whose param does not fit renders "not found" before any request is made
|
||||||
|
// (components/ValidParam.tsx). api.ts's urlPath encodes and refuses on its own;
|
||||||
|
// this keeps a crafted link from reaching a page at all.
|
||||||
|
|
||||||
|
/** Server names are DNS-1123 labels; internal/naming holds the stricter rule a
|
||||||
|
* new server's name must meet, so every existing server fits this one. */
|
||||||
|
export const SERVER_NAME_PARAM = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
|
||||||
|
/** User ids are opaque: 32 hex characters or a UUID today. */
|
||||||
|
export const USER_ID_PARAM = /^[A-Za-z0-9_-]{1,128}$/;
|
||||||
+82
-5
@@ -8,11 +8,11 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Identity } from "./types";
|
import type { Identity } from "./types";
|
||||||
import { api } from "./api";
|
import { api, SESSION_EXPIRED_EVENT } from "./api";
|
||||||
import { deriveAuth, type AuthState } from "./auth";
|
import { deriveAuth, isUnauthorized, type AuthState } from "./auth";
|
||||||
|
|
||||||
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
|
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
|
||||||
// the result through context. Three design rules, all load-bearing:
|
// the result through context. Four design rules, all load-bearing:
|
||||||
//
|
//
|
||||||
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
|
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
|
||||||
// flight (`identity === null`) or after it rejects, isAdmin is false — admin
|
// flight (`identity === null`) or after it rejects, isAdmin is false — admin
|
||||||
@@ -29,6 +29,12 @@ import { deriveAuth, type AuthState } from "./auth";
|
|||||||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
||||||
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||||||
//
|
//
|
||||||
|
// 4. Session-aware while open: a 401 from any protected call (api.ts announces
|
||||||
|
// SESSION_EXPIRED_EVENT), the tab coming back into view, or the window
|
||||||
|
// regaining focus re-reads /me through `revalidate()`. It leaves `loading`
|
||||||
|
// alone, so the app stays mounted during the check; only a 401 changes what
|
||||||
|
// is shown, and `sessionEnded` tells the login page why the person landed there.
|
||||||
|
//
|
||||||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||||||
|
|
||||||
export interface TierState extends AuthState {
|
export interface TierState extends AuthState {
|
||||||
@@ -38,8 +44,17 @@ export interface TierState extends AuthState {
|
|||||||
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
||||||
* navigation after the context has settled (login → refresh → redirect). */
|
* navigation after the context has settled (login → refresh → redirect). */
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
|
/** Re-read /me in the background, keeping the app mounted. A 401 signs the
|
||||||
|
* person out (with sessionEnded set); any other failure changes nothing. */
|
||||||
|
revalidate: () => Promise<void>;
|
||||||
|
/** True once a signed-in session was found to have ended (expired or revoked). */
|
||||||
|
sessionEnded: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A tab returning to view re-checks /me at most this often; a 401 from a real
|
||||||
|
// call re-checks at once.
|
||||||
|
const REVALIDATE_EVERY_MS = 60_000;
|
||||||
|
|
||||||
const TierContext = createContext<TierState>({
|
const TierContext = createContext<TierState>({
|
||||||
identity: null,
|
identity: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -47,6 +62,8 @@ const TierContext = createContext<TierState>({
|
|||||||
isOwner: false,
|
isOwner: false,
|
||||||
unauthenticated: false,
|
unauthenticated: false,
|
||||||
refresh: async () => {},
|
refresh: async () => {},
|
||||||
|
revalidate: async () => {},
|
||||||
|
sessionEnded: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function TierProvider({ children }: { children: ReactNode }) {
|
export function TierProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -58,24 +75,67 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
|||||||
// resolving after a subsequent login's): only the latest call commits its result.
|
// resolving after a subsequent login's): only the latest call commits its result.
|
||||||
const seq = useRef(0);
|
const seq = useRef(0);
|
||||||
|
|
||||||
|
const [sessionEnded, setSessionEnded] = useState(false);
|
||||||
|
const loadingRef = useRef(true);
|
||||||
|
const identityRef = useRef<Identity | null>(null);
|
||||||
|
const revalidating = useRef(false);
|
||||||
|
const lastCheck = useRef(0);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const ticket = ++seq.current;
|
const ticket = ++seq.current;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const id = await api.me();
|
const id = await api.me();
|
||||||
if (ticket === seq.current) {
|
if (ticket === seq.current) {
|
||||||
|
identityRef.current = id;
|
||||||
setIdentity(id);
|
setIdentity(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSessionEnded(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep the error so deriveAuth can tell a 401 (→ login) from a transient
|
// Keep the error so deriveAuth can tell a 401 (→ login) from a transient
|
||||||
// failure (→ stay functional). Identity is cleared either way.
|
// failure (→ stay functional). Identity is cleared either way.
|
||||||
if (ticket === seq.current) {
|
if (ticket === seq.current) {
|
||||||
|
identityRef.current = null;
|
||||||
setIdentity(null);
|
setIdentity(null);
|
||||||
setError(e);
|
setError(e);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (ticket === seq.current) setLoading(false);
|
if (ticket === seq.current) {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
lastCheck.current = Date.now();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const revalidate = useCallback(async () => {
|
||||||
|
// A refresh in flight answers the same question and owns the loading flag;
|
||||||
|
// bumping the ticket under it would leave the spinner up for good.
|
||||||
|
if (loadingRef.current || revalidating.current) return;
|
||||||
|
revalidating.current = true;
|
||||||
|
const ticket = ++seq.current;
|
||||||
|
try {
|
||||||
|
const id = await api.me();
|
||||||
|
if (ticket === seq.current) {
|
||||||
|
// An unchanged identity keeps its object so nothing re-renders.
|
||||||
|
if (JSON.stringify(id) !== JSON.stringify(identityRef.current)) {
|
||||||
|
identityRef.current = id;
|
||||||
|
setIdentity(id);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (ticket === seq.current && isUnauthorized(e)) {
|
||||||
|
if (identityRef.current !== null) setSessionEnded(true);
|
||||||
|
identityRef.current = null;
|
||||||
|
setIdentity(null);
|
||||||
|
setError(e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
revalidating.current = false;
|
||||||
|
lastCheck.current = Date.now();
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -83,6 +143,23 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onExpired = () => void revalidate();
|
||||||
|
const onReturn = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
if (Date.now() - lastCheck.current < REVALIDATE_EVERY_MS) return;
|
||||||
|
void revalidate();
|
||||||
|
};
|
||||||
|
window.addEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||||
|
window.addEventListener("focus", onReturn);
|
||||||
|
document.addEventListener("visibilitychange", onReturn);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SESSION_EXPIRED_EVENT, onExpired);
|
||||||
|
window.removeEventListener("focus", onReturn);
|
||||||
|
document.removeEventListener("visibilitychange", onReturn);
|
||||||
|
};
|
||||||
|
}, [revalidate]);
|
||||||
|
|
||||||
const state = deriveAuth(identity, error, loading);
|
const state = deriveAuth(identity, error, loading);
|
||||||
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
|
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
|
||||||
// backend grades by operation), but isOwner gates user management. Both are
|
// backend grades by operation), but isOwner gates user management. Both are
|
||||||
@@ -90,7 +167,7 @@ export function TierProvider({ children }: { children: ReactNode }) {
|
|||||||
const isOwner = identity?.is_owner === true;
|
const isOwner = identity?.is_owner === true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TierContext.Provider value={{ ...state, isOwner, refresh }}>
|
<TierContext.Provider value={{ ...state, isOwner, refresh, revalidate, sessionEnded }}>
|
||||||
{children}
|
{children}
|
||||||
</TierContext.Provider>
|
</TierContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type StreamStatus,
|
type StreamStatus,
|
||||||
type LogLine,
|
type LogLine,
|
||||||
} from "./logstream";
|
} from "./logstream";
|
||||||
|
import { useTier } from "./tier";
|
||||||
|
|
||||||
// browserEventSource is the production factory: a native EventSource with
|
// browserEventSource is the production factory: a native EventSource with
|
||||||
// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's
|
// credentials, so the Cloudflare Access cookie rides along exactly as api.ts's
|
||||||
@@ -46,6 +47,14 @@ export function useLogStream(url: string): UseLogStream {
|
|||||||
|
|
||||||
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
||||||
|
|
||||||
|
// EventSource reports a refused stream (a 401 included) only as an error, so
|
||||||
|
// an ended stream re-checks the session: an expired one takes the person to
|
||||||
|
// sign in, and otherwise the console shows the stream as ended.
|
||||||
|
const { revalidate } = useTier();
|
||||||
|
useEffect(() => {
|
||||||
|
if (snapshot.status === "ended") void revalidate();
|
||||||
|
}, [snapshot.status, revalidate]);
|
||||||
|
|
||||||
const clear = useCallback(() => controller.clear(), [controller]);
|
const clear = useCallback(() => controller.clear(), [controller]);
|
||||||
const reconnect = useCallback(() => controller.reconnect(), [controller]);
|
const reconnect = useCallback(() => controller.reconnect(), [controller]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, type FormEvent } from "react";
|
import { useState, useEffect, type FormEvent } from "react";
|
||||||
import { Navigate, useNavigate } from "react-router-dom";
|
import { Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
|
import { Loader2, KeyRound, Mail, Fingerprint, ShieldCheck } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AuthLayout } from "@/components/AuthLayout";
|
import { AuthLayout } from "@/components/AuthLayout";
|
||||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
|
import { loginReturnPath } from "@/lib/auth";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { loadConfig } from "@/lib/config";
|
import { loadConfig } from "@/lib/config";
|
||||||
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
@@ -20,11 +21,17 @@ import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
|||||||
// re-evaluates and land on the dashboard.
|
// re-evaluates and land on the dashboard.
|
||||||
//
|
//
|
||||||
// Reaching this page already-authenticated (e.g. typing /login while signed in)
|
// Reaching this page already-authenticated (e.g. typing /login while signed in)
|
||||||
// short-circuits to the dashboard rather than showing the form.
|
// short-circuits to the dashboard rather than showing the form. RequireAuth sends
|
||||||
|
// a signed-out visitor here with ?next= (the page they were on) and, when their
|
||||||
|
// session ended under an open page, state.sessionEnded, which shows why; after
|
||||||
|
// signing in they land back on that page.
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { loading, identity, refresh } = useTier();
|
const { loading, identity, refresh } = useTier();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const next = loginReturnPath(searchParams.get("next"));
|
||||||
|
const sessionEnded = (useLocation().state as { sessionEnded?: boolean } | null)?.sessionEnded === true;
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
|
const [activeTab, setActiveTab] = useState<"main" | "bind" | "op">("main");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -89,7 +96,7 @@ export function Login() {
|
|||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (identity) return <Navigate to="/" replace />;
|
if (identity) return <Navigate to={next} replace />;
|
||||||
|
|
||||||
async function handleBindSubmit(e: FormEvent) {
|
async function handleBindSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -100,7 +107,7 @@ export function Login() {
|
|||||||
try {
|
try {
|
||||||
await api.bind(code);
|
await api.bind(code);
|
||||||
await refresh();
|
await refresh();
|
||||||
navigate("/", { replace: true });
|
navigate(next, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -130,7 +137,7 @@ export function Login() {
|
|||||||
try {
|
try {
|
||||||
await api.authEmailVerify(email.trim(), otpCode.trim());
|
await api.authEmailVerify(email.trim(), otpCode.trim());
|
||||||
await refresh();
|
await refresh();
|
||||||
navigate("/", { replace: true });
|
navigate(next, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -220,7 +227,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
navigate("/", { replace: true });
|
navigate(next, { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -251,7 +258,7 @@ export function Login() {
|
|||||||
try {
|
try {
|
||||||
await api.opLoginFinish(opRequestId, opCode.trim());
|
await api.opLoginFinish(opRequestId, opCode.trim());
|
||||||
await refresh();
|
await refresh();
|
||||||
navigate("/", { replace: true });
|
navigate(next, { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -268,6 +275,14 @@ export function Login() {
|
|||||||
title={t("login_title")}
|
title={t("login_title")}
|
||||||
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
|
subtitle={t(isOpHost ? "login_subtitle_op" : "login_subtitle")}
|
||||||
>
|
>
|
||||||
|
{sessionEnded && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
{t("session_ended_notice")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{activeTab === "main" && (
|
{activeTab === "main" && (
|
||||||
|
|||||||
Reference in new issue
Block a user