Unverified Commit 6595a2c5 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 路径参数逐段编码并拒绝点段,非 JSON 响应保留状态码,会话过期带回跳送回登录页,连不上时显示横幅

parent e0fa0268
Loading
Loading
Loading
Loading
+17 −6
Changes for panel/src/App.tsx: 17 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,8 @@ import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
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 { Setup } from "@/pages/Setup";
import { Dashboard } from "@/pages/Dashboard";
@@ -70,11 +72,18 @@ export default function App() {
              {/* User-Side — app-tier */}
              <Route index element={<Dashboard />} />
              <Route path="servers" element={<ServersPage />} />
              <Route path="servers/:name" element={<ServerConsole />} />
              <Route path="servers/:name/players" element={<ServerPlayers />} />
              <Route path="servers/:name/backups" element={<ServerBackups />} />
              <Route path="servers/:name/files" element={<ServerFiles />} />
              <Route path="servers/:name/luckperms" element={<ServerLuckPerms />} />
              {/* A :name that is not a server name (a crafted link carrying
                  "/", "?" or "..") renders "not found" before any request. */}
              <Route
                path="servers/:name"
                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="account" element={<Account />} />

@@ -90,7 +99,9 @@ export default function App() {
                {/* Owner-gated: user management (one level above admin). */}
                <Route element={<RequireOwner />}>
                  <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>

+34 −2
Changes for panel/src/components/AppShell.tsx: 34 added lines, 2 removed lines.
Original line number Diff line number Diff line
import { useEffect, useState } from "react";
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 * as SelectPrimitive from "@radix-ui/react-select";
import { cn } from "@/lib/utils";
@@ -8,7 +8,8 @@ import { useTier } from "@/lib/tier";
import { visibleSections, type NavSection } from "@/lib/nav";
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
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";

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() {
  const { isAdmin, isOwner } = useTier();
  const { pathname } = useLocation();
@@ -190,6 +221,7 @@ export function AppShell() {
      </aside>

      <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">
          <div className="flex items-center gap-2">
            <Cat className="h-5 w-5 text-primary" />
+11 −4
Changes for panel/src/components/RequireAuth.tsx: 11 added lines, 4 removed lines.
Original line number Diff line number Diff line
import { Navigate, Outlet } from "react-router-dom";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
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:
//
//   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/>)
//
// 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
// 401 bounces to /login. Every admin route remains independently server-guarded.
export function RequireAuth() {
  const { loading, unauthenticated } = useTier();
  const { loading, unauthenticated, sessionEnded } = useTier();
  const { t } = useTranslation("common");
  const location = useLocation();

  if (loading) {
    return (
@@ -26,6 +29,10 @@ export function RequireAuth() {
      </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 />;
}
+17 −1
Changes for panel/src/components/States.tsx: 17 added lines, 1 removed line.
Original line number Diff line number Diff line
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 { useTranslation } from "react-i18next";
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
 * not exist yet (DESIGN-WEB-3SIDES non-goal #3: "a page with no real backend
+10 −0
Changes for panel/src/components/ValidParam.tsx: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
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 />;
}
Loading