Files
punktfunk/web/src/sections/Store/index.tsx
T
enricobuehlerandClaude Opus 5 4575134c21 fix(web): one bad password from anywhere stops locking out the whole console
The console's login throttle was documented as per-IP and was not. Nitro's
`localFetch` hands the app a synthetic request whose socket has no
`remoteAddress`, so `getRequestIP()` returned undefined for every request and
every attempt was charged to one shared "unknown" bucket. Five wrong guesses
from any LAN peer locked out everyone — including the operator, and including
the update-apply route, which shares that budget. The Bun entry is the only
place the real peer is knowable, so it now stamps it into a header (deleting
any client-supplied copy first) and `peerAddress()` reads it back.

Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1
lock 127.0.0.1 out, a different peer still logs in on the first try, and a
request forging the header is charged to its real address.

Also on the way through:

- Installing an unreviewed package and adding a catalog source now re-ask for
  the console password, like applying an update already did. A 7-day session
  cookie should not be able to run new code on the host, and `store/install`
  with `accept_unverified` did exactly that through the generic passthrough.
  The gate sits at the trust boundary — adding a source, or a raw spec — not
  on every install from a source the operator already chose to trust.
- The ui-credential denylist is matched against the normalised path too, so
  `/api//v1/...` and friends can no longer walk around it.
- The console serves nosniff, a no-referrer policy, and a CSP that pins
  frame-ancestors, object-src and base-uri.
- A plugin UI's response no longer re-emits the content-encoding that `fetch`
  already decoded (which made compressed plugin pages fail to load), no longer
  sets cookies on the console's origin, and OPTIONS reaches the plugin instead
  of being refused 405 by us.
- An unreachable host reads as 502 on these routes, matching the passthrough,
  instead of a bare 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:20:10 +02:00

188 lines
5.8 KiB
TypeScript

import Section from "@unom/ui/section";
import { toast } from "@unom/ui/toast";
import { type FC, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
type InstallBody,
type InstalledPlugin,
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
useUninstallPlugin,
} from "@/api/store";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { BrowseTab } from "./Browse";
import { InstallDialog, SpecInstallDialog } from "./InstallDialogs";
import { InstalledTab } from "./Installed";
import { JobProgressSection } from "./JobProgress";
import { SourcesTab } from "./Sources";
type StoreTab = "browse" | "installed" | "sources";
/**
* The plugin store: browse a catalog, manage what's installed, and choose which catalogs this host
* trusts. Each tab owns its own queries; this container owns only what genuinely spans them — the
* install/uninstall mutations, the confirm dialogs their trust tier dictates, and the job the host
* hands back (which must stay visible whichever tab you switch to while it runs).
*/
export const SectionStore: FC = () => {
useLocale();
const [tab, setTab] = useState<StoreTab>("browse");
// The catalog entry awaiting its install confirmation, and the raw-spec dialog's open state.
const [target, setTarget] = useState<StoreEntry | null>(null);
const [specOpen, setSpecOpen] = useState(false);
const [specWrongPassword, setSpecWrongPassword] = useState(false);
// The job the host is running for us, if any. Cleared by the operator, not by completion — a
// finished job's log is the only record of what happened.
const [jobId, setJobId] = useState<string | null>(null);
const catalog = useStoreCatalog();
const install = useInstallPlugin();
const uninstall = useUninstallPlugin();
/** Turn a failed 202-request into a message: 409 means the host is busy, not that we're broken. */
const failed = (e: unknown, fallback: string) =>
toast.error(
e instanceof ApiError && e.status === 409 ? m.store_busy() : fallback,
);
const start = async (body: InstallBody) => {
try {
const { job } = await install.mutateAsync(body);
setJobId(job);
} catch (e) {
failed(e, m.store_install_failed());
}
};
const onConfirmEntry = async (entry: StoreEntry) => {
setTarget(null);
await start({ source: entry.source, id: entry.id });
};
const onConfirmSpec = async (spec: string, password: string) => {
setSpecWrongPassword(false);
try {
const { job } = await install.mutateAsync({
spec,
accept_unverified: true,
password,
});
setSpecOpen(false);
setJobId(job);
} catch (e) {
// A rejected password keeps the dialog open with everything the operator typed still in
// it; anything else is an ordinary install failure.
if (e instanceof ApiError && e.status === 401) {
setSpecWrongPassword(true);
return;
}
setSpecOpen(false);
failed(e, m.store_install_failed());
}
};
// An update from the Installed tab installs the CATALOG version — so it goes through the very
// same tier-appropriate dialog a fresh install would, warning included.
//
// Resolve by the entry the plugin was actually installed FROM (source + entry id) before falling
// back to the package name: two sources may carry the same `pkg`, and matching on the name alone
// could offer a row badged "verified" an entry from somebody else's source at a different version.
const onUpdate = (plugin: InstalledPlugin) => {
const entries = catalog.data?.plugins ?? [];
const entry =
(plugin.source && plugin.entry_id
? entries.find(
(e) => e.source === plugin.source && e.id === plugin.entry_id,
)
: undefined) ??
(plugin.source
? entries.find(
(e) => e.source === plugin.source && e.pkg === plugin.pkg,
)
: undefined) ??
entries.find((e) => e.pkg === plugin.pkg);
if (!entry) {
toast.error(m.store_update_no_entry());
return;
}
setTarget(entry);
};
const onUninstall = async (plugin: InstalledPlugin) => {
if (
!confirm(m.store_uninstall_confirm({ title: plugin.title ?? plugin.pkg }))
)
return;
try {
const { job } = await uninstall.mutateAsync(plugin.pkg);
setJobId(job);
} catch (e) {
failed(e, m.store_uninstall_failed());
}
};
return (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.store_title()}</h1>
<p className="text-sm text-muted-foreground">{m.store_subtitle()}</p>
</div>
{jobId && (
<JobProgressSection jobId={jobId} onDismiss={() => setJobId(null)} />
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as StoreTab)}>
<TabsList>
<TabsTrigger value="browse">{m.store_tab_browse()}</TabsTrigger>
<TabsTrigger value="installed">
{m.store_tab_installed()}
</TabsTrigger>
<TabsTrigger value="sources">{m.store_tab_sources()}</TabsTrigger>
</TabsList>
<TabsContent value="browse">
<BrowseTab
onInstall={setTarget}
onInstallSpec={() => setSpecOpen(true)}
/>
</TabsContent>
<TabsContent value="installed">
<InstalledTab
onUpdate={onUpdate}
onUninstall={onUninstall}
busyPkg={
uninstall.isPending ? (uninstall.variables ?? null) : null
}
/>
</TabsContent>
<TabsContent value="sources">
<SourcesTab />
</TabsContent>
</Tabs>
<InstallDialog
entry={target}
isPending={install.isPending}
onCancel={() => setTarget(null)}
onConfirm={onConfirmEntry}
/>
<SpecInstallDialog
open={specOpen}
isPending={install.isPending}
wrongPassword={specWrongPassword}
onCancel={() => {
setSpecOpen(false);
setSpecWrongPassword(false);
}}
onConfirm={onConfirmSpec}
/>
</div>
</Section>
);
};