forked from unom/punktfunk
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>
188 lines
5.8 KiB
TypeScript
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>
|
|
);
|
|
};
|