Author SHA1 Message Date
pastilhas f7c9a28c33 a login through the proxy lost its csrf cookie, and looped
`stripHopByHop` copies headers with `out.set(key, value)`, which overwrites.
Correct for every header except the one that legitimately repeats: a NextAuth
sign-in sends several Set-Cookie - csrf-token, callback-url, session-token - and
collapsing them to one drops the rest.

Measured: two Set-Cookie headers direct from Linkwarden, one through the door,
and the survivor was callback-url. So the csrf cookie never reached the browser,
the sign-in posted, failed its CSRF check and bounced back to the form. A
redirect loop with nothing in any log saying why - and the owner predicted this
exact symptom from the other side before it was found.

`getSetCookie()` returns them unjoined, which is the reason to use it rather than
splitting a combined value: `Expires` contains a comma, so naive splitting
corrupts the date and the cookie is discarded.

Both hops use this one function, so both are fixed by it.
2026-09-05 21:23:48 +01:00
pastilhas 05d51302ec somewhere to go, because the settings screen was the whole plugin
The owner installed it, landed on the configuration form, and could not get off
it. Not a bug in the form: there was nothing behind it. One panel was registered
and it was the settings.

That is the wrong shape for a plugin regardless of how much is finished. A screen
whose only content is its own configuration reads as broken rather than
unfinished, because the first thing anybody does after installing is look for the
way forward.

So: collections on the left, links on the right, and the settings behind the gear
in the collections panel - reachable when something is wrong and out of the way
when it is not. Selection is `?collection=` in the URL rather than a channel, so
which collection you are looking at survives a reload and can be linked to.

Search is Linkwarden's own `searchQueryString`, which searches names,
descriptions, tags AND the archived page text - the thing a bookmark bar cannot
do, and the reason this is worth more than a list of addresses. Doing it here
would mean pulling every link to the browser and would still only see the fields
we happened to fetch. Verified against the instance: 50 unfiltered, 8 for "arch",
0 for a string matching nothing.

The panel holds no credential and could not send one. Every request goes through
the authenticated door, which finds the caller's mapped account and puts their
token on. `no Linkwarden account is mapped to you` is the state every user starts
in, so it is said as an instruction rather than rendered as an outage.
2026-09-05 21:17:21 +01:00
8 changed files with 420 additions and 163 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
version: '2026.09.05.03', version: '2026.09.05.05',
platform: '>=1.0.0', platform: '>=1.0.0',
label: 'Linkwarden', label: 'Linkwarden',
+19
View File
@@ -99,6 +99,25 @@ export function stripResponseHeaders(src: Headers): Headers {
const out = stripHopByHop(src); const out = stripHopByHop(src);
out.delete('content-encoding'); out.delete('content-encoding');
out.delete('content-length'); out.delete('content-length');
/*
── `Set-Cookie` is the one header that legitimately repeats, and `.set()` keeps only the last ──
`stripHopByHop` copies with `out.set(key, value)`, which OVERWRITES. Everything else is fine that
way; Set-Cookie is not. A NextAuth sign-in sends several — csrf-token, callback-url, session-token —
and collapsing them to one silently drops the rest.
Measured on this install: two headers direct from Linkwarden, one through the proxy, and the one
that survived was `callback-url`. The csrf cookie never reached the browser, which is a sign-in that
posts, fails its CSRF check, and bounces back to the form — the redirect loop, with nothing in any
log to say why.
`getSetCookie()` is the standard accessor for exactly this case: it returns the values unjoined,
which matters because a cookie's `Expires` contains a comma and naive comma-splitting corrupts it.
*/
out.delete('set-cookie');
for (const cookie of src.getSetCookie()) out.append('set-cookie', cookie);
return out; return out;
} }
+113
View File
@@ -0,0 +1,113 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { Folder, Settings2, Layers } from 'lucide-react';
import { SettingsForm } from './SettingsForm';
/**
* The collections, and a way back to the settings.
*
* Selection is `?collection=<id>` in the URL rather than a channel, so which collection you are looking
* at survives a reload and can be linked to — `docs/navigation-audit.md`. The links panel reads the same
* parameter rather than being handed the id.
*
* The settings live behind the gear rather than on a screen of their own. They were a screen of their
* own for an afternoon and it was the wrong shape: nothing else was built, so opening the plugin put you
* on a configuration form with no way forward, which reads as broken rather than unfinished.
*/
type Collection = { id: number; name: string; _count?: { links?: number } };
/** `?collection=` for "everything", which is a real choice and not the absence of one. */
export const ALL = 'all';
export const CollectionsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [rows, setRows] = useState<Collection[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [params, setParams] = useSearchParams();
const selected = params.get('collection') ?? ALL;
const load = useCallback(async () => {
try {
const body = await clientRef.current.get<{ response: Collection[] }>('/linkwarden/api/v1/collections');
setRows(body.response ?? []);
setError(null);
} catch (failure) {
// The message matters here. "No account is mapped to you" is something the owner can fix and is
// the state every user starts in, so it must not read as an outage.
setError(failure instanceof Error ? failure.message : 'Could not read your collections.');
setRows(null);
setShowSettings(true);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const select = (id: string) =>
setParams(
(p) => {
p.set('collection', id);
return p;
},
{ replace: true },
);
return (
<div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-2 py-1.5">
<span className="text-xs font-medium">Collections</span>
<button
type="button"
onClick={() => setShowSettings((was) => !was)}
className="p-1 text-muted-foreground hover:text-foreground"
aria-label="Settings"
>
<Settings2 className="size-3.5" />
</button>
</header>
{error && <p className="px-3 py-2 text-[11px] text-destructive">{error}</p>}
<ul className="flex-1 overflow-auto">
<li>
<button
type="button"
onClick={() => select(ALL)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
selected === ALL ? 'bg-muted' : ''
}`}
>
<Layers className="size-3.5 shrink-0 text-muted-foreground" />
Everything
</button>
</li>
{rows?.map((row) => (
<li key={row.id}>
<button
type="button"
onClick={() => select(String(row.id))}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted/50 ${
selected === String(row.id) ? 'bg-muted' : ''
}`}
>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{row.name}</span>
{typeof row._count?.links === 'number' && (
<span className="shrink-0 text-[10px] text-muted-foreground">{row._count.links}</span>
)}
</button>
</li>
))}
</ul>
{showSettings && <SettingsForm />}
</div>
);
};
+138
View File
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useClient } from 'hooks/useClient';
import { Input } from '@/components/ui/input';
import { ExternalLink, FileText } from 'lucide-react';
import { ALL } from './CollectionsPanel';
/**
* Your saved links, as the calling Officer user.
*
* Every request goes through `/api/linkwarden/api/*` — the authenticated door, which finds the caller's
* mapped account and puts THEIR token on the request. This panel holds no credential and could not send
* one if it wanted to, which is the property the whole plugin exists for.
*
* ── Search is Linkwarden's, not ours ──
*
* `searchQueryString` searches names, descriptions, tags AND the archived page text, which is the thing
* a bookmark bar cannot do. Re-implementing it here would mean pulling every link to the browser to
* filter it, and would still only see the fields we happened to fetch. Verified against the live
* instance: 50 links unfiltered, 8 for "arch", 0 for a string that matches nothing.
*/
type Link = {
id: number;
name: string;
url: string;
description?: string;
textContent?: string;
collection?: { id: number; name: string };
tags?: { id: number; name: string }[];
createdAt?: string;
};
export const LinksPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [params] = useSearchParams();
const collection = params.get('collection') ?? ALL;
const [query, setQuery] = useState('');
const [rows, setRows] = useState<Link[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (collectionId: string, search: string) => {
const qs = new URLSearchParams();
if (collectionId !== ALL) qs.set('collectionId', collectionId);
if (search.trim()) qs.set('searchQueryString', search.trim());
try {
const body = await clientRef.current.get<{ response: Link[] }>(
`/linkwarden/api/v1/links${qs.toString() ? `?${qs}` : ''}`,
);
setRows(body.response ?? []);
setError(null);
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'Could not read your links.');
setRows(null);
}
}, []);
/*
Debounced, because this types straight into a request.
250 ms is chosen against what it is: somebody typing a word, who wants the list to move while they
type rather than after they stop. Long enough that a five-letter word is one query instead of five.
*/
useEffect(() => {
const timer = setTimeout(() => void load(collection, query), 250);
return () => clearTimeout(timer);
}, [collection, query, load]);
return (
<div className="flex size-full flex-col">
<header className="border-b p-2">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search names, tags and the text of saved pages…"
className="h-8 text-xs"
/>
</header>
{error && (
<p className="px-3 py-2 text-xs text-destructive">
{error}
{/* The state every user starts in, said as an instruction rather than a failure. */}
{error.includes('no Linkwarden account') && ' — the owner maps accounts in Settings.'}
</p>
)}
{rows === null && !error ? (
<Centred>Loading…</Centred>
) : rows && rows.length === 0 ? (
<Centred>{query.trim() ? `Nothing matches “${query.trim()}”.` : 'Nothing saved here yet.'}</Centred>
) : (
<ul className="flex-1 divide-y overflow-auto">
{rows?.map((link) => (
<li key={link.id} className="group px-3 py-2 hover:bg-muted/40">
<div className="flex items-start gap-2">
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<a
href={link.url}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-1 text-xs hover:underline"
>
<span className="min-w-0 truncate">{link.name || link.url}</span>
<ExternalLink className="size-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-60" />
</a>
<p className="truncate text-[11px] text-muted-foreground">{link.url}</p>
{(link.collection || link.tags?.length) && (
<p className="mt-0.5 flex flex-wrap items-center gap-1 text-[10px] text-muted-foreground">
{link.collection && <span>{link.collection.name}</span>}
{link.tags?.map((tag) => (
<span key={tag.id} className="rounded bg-muted px-1 py-px">
{tag.name}
</span>
))}
</p>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
{children}
</div>
);
-149
View File
@@ -1,149 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/**
* Where this install's Linkwarden is, and whether it answers.
*
* ── Two addresses, and they are not the same one ──
*
* The field below is the address the SIDECAR dials, which on a machine running both Officer and
* Linkwarden is loopback. It is NOT the address you give a browser or the extension — that one is
* `NEXTAUTH_URL` inside the Linkwarden container, and it has to be reachable from wherever the client
* is. Measured on this install: the name Linkwarden advertises resolves to 127.0.1.1 on the host it
* runs on, where nothing is bound, so a sidecar told to use it would dial a name that answers from
* everywhere except the one place that matters. The panel says so rather than leaving it to be
* rediscovered.
*/
type Config = { url: string | null; enabled: boolean; updatedAt: string | null };
type Health = { ok: boolean; upstream?: number; reason?: string };
export const LinkwardenPanel = () => {
const client = useClient();
// `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list — the
// trap that silently disabled every playback report in the Jellyfin player for days.
const clientRef = useRef(client);
clientRef.current = client;
const [config, setConfig] = useState<Config | null>(null);
const [url, setUrl] = useState('');
const [adminToken, setAdminToken] = useState('');
const [health, setHealth] = useState<Health | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const load = useCallback(async () => {
try {
const next = await clientRef.current.get<Config>('/linkwarden/config');
setConfig(next);
setUrl(next.url ?? '');
setError(null);
} catch {
setError('Could not read the configuration.');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const check = useCallback(async () => {
setHealth(null);
try {
setHealth(await clientRef.current.get<Health>('/linkwarden/config/health'));
} catch {
setHealth({ ok: false, reason: 'the check itself failed' });
}
}, []);
const save = async () => {
setBusy(true);
setSaved(false);
try {
// The token is sent only when one was typed. Sending an empty string would read as "clear it",
// which is not what leaving a write-only field alone means.
await clientRef.current.put('/linkwarden/config', {
url: url.trim() || null,
...(adminToken.trim() ? { adminToken: adminToken.trim() } : {}),
});
setAdminToken('');
setSaved(true);
setError(null);
await load();
await check();
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'Could not save that.');
} finally {
setBusy(false);
}
};
return (
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
<header>
<h2 className="text-sm font-medium">Linkwarden</h2>
<p className="text-xs text-muted-foreground">
One instance for this Officer. Officer stores no bookmarks — it holds the credential and the door.
</p>
</header>
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{error}
</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Address the sidecar dials</label>
<Input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="http://127.0.0.1:3000" />
<p className="text-[11px] text-muted-foreground">
Loopback when Linkwarden runs on this machine. This is <em>not</em> the address you give the
browser extension — that one is <code>NEXTAUTH_URL</code> in Linkwarden’s own container, and it
has to be reachable from wherever the client is.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Admin access token</label>
<Input
type="password"
value={adminToken}
onChange={(event) => setAdminToken(event.target.value)}
placeholder={config?.url ? 'unchanged' : 'from Linkwarden → Settings → Access Tokens'}
/>
<p className="text-[11px] text-muted-foreground">
Used only to create accounts for Officer users. Write-only: it is never sent back to this screen,
and it is not the token any proxied request carries.
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={() => void save()} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button size="sm" variant="secondary" onClick={() => void check()}>
Test
</Button>
{saved && <span className="text-xs text-muted-foreground">Saved.</span>}
</div>
{health && (
<p className="text-xs">
{health.ok ? (
<span className="text-muted-foreground">Answering{health.upstream ? ` (${health.upstream})` : ''}.</span>
) : (
/*
The reasons are distinct on purpose. "Unconfigured" is the owner not having finished,
"unreachable" is the address being wrong or the instance down, and "sidecar not running" is
Officer's own problem — three different next actions, and one word for all three would send
somebody to check the wrong thing.
*/
<span className="text-destructive">Not answering — {health.reason ?? 'no reason given'}.</span>
)}
</p>
)}
</div>
);
};
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/**
* Where this install's Linkwarden is, and whether it answers.
*
* Was the whole plugin for an afternoon, which was the mistake: a settings screen with nothing behind it
* is a screen you cannot leave. It is a disclosure inside the collections panel now — reachable when
* something is wrong, and out of the way when it is not.
*
* ── Two addresses, and they are not the same one ──
*
* The field below is the address the SIDECAR dials, which on a machine running both Officer and
* Linkwarden is loopback. It is NOT the address a browser or the extension is given — that is
* `NEXTAUTH_URL` inside Linkwarden's own container, and it has to be reachable from wherever the client
* is. Measured on this install: the name Linkwarden advertises resolves to 127.0.1.1 on the host it runs
* on, where nothing is bound, so a sidecar told to use it dials a name that answers from everywhere
* except the one place that matters.
*/
type Config = { url: string | null; enabled: boolean; updatedAt: string | null };
type Health = { ok: boolean; upstream?: number; reason?: string };
export const SettingsForm = () => {
const client = useClient();
// `useClient()` rebuilds its verbs every render, so they must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [config, setConfig] = useState<Config | null>(null);
const [url, setUrl] = useState('');
const [adminToken, setAdminToken] = useState('');
const [health, setHealth] = useState<Health | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const next = await clientRef.current.get<Config>('/linkwarden/config');
setConfig(next);
setUrl(next.url ?? '');
setError(null);
} catch {
setError('Could not read the configuration.');
}
}, []);
useEffect(() => {
void load();
}, [load]);
const check = useCallback(async () => {
setHealth(null);
try {
setHealth(await clientRef.current.get<Health>('/linkwarden/config/health'));
} catch {
setHealth({ ok: false, reason: 'the check itself failed' });
}
}, []);
const save = async () => {
setBusy(true);
try {
// The token is sent only when one was typed: leaving a write-only field alone means "unchanged",
// not "clear it".
await clientRef.current.put('/linkwarden/config', {
url: url.trim() || null,
...(adminToken.trim() ? { adminToken: adminToken.trim() } : {}),
});
setAdminToken('');
setError(null);
await load();
await check();
} catch (failure) {
setError(failure instanceof Error ? failure.message : 'Could not save that.');
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3 border-t p-3">
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="space-y-1">
<label className="text-[11px] text-muted-foreground">Address the sidecar dials</label>
<Input
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="http://127.0.0.1:3000"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] text-muted-foreground">Admin access token</label>
<Input
type="password"
value={adminToken}
onChange={(event) => setAdminToken(event.target.value)}
placeholder={config?.url ? 'unchanged' : 'Linkwarden → Settings → Access Tokens'}
className="h-8 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Button size="sm" className="h-7 text-xs" onClick={() => void save()} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</Button>
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => void check()}>
Test
</Button>
</div>
{health && (
<p className="text-[11px]">
{health.ok ? (
<span className="text-muted-foreground">Answering{health.upstream ? ` (${health.upstream})` : ''}.</span>
) : (
/*
Three distinct reasons on purpose — unconfigured, unreachable, sidecar not running are three
different next actions, and one word for all of them sends somebody to check the wrong thing.
*/
<span className="text-destructive">Not answering — {health.reason ?? 'no reason given'}.</span>
)}
</p>
)}
</div>
);
};
+6 -4
View File
@@ -1,11 +1,13 @@
import type { LayoutNode } from 'officerdev'; import type { LayoutNode } from 'officerdev';
// A single full-width panel. The framework requires a layout even when there is only one thing in it — // The links are the screen; the collections are how you narrow them. 24/76 because a collection name is
// a plugin contributes panels and an arrangement, never a screen — and this is that arrangement stated // short and a saved page needs room for its title, its address and its tags without wrapping.
// honestly rather than padded out with a sidebar nothing needs yet.
export const defaultLayout: LayoutNode = { export const defaultLayout: LayoutNode = {
type: 'group', type: 'group',
id: 'linkwarden-root', id: 'linkwarden-root',
direction: 'horizontal', direction: 'horizontal',
children: [{ node: { type: 'panel', id: 'linkwarden-settings', appType: 'linkwarden-settings' }, size: 100 }], children: [
{ node: { type: 'panel', id: 'linkwarden-collections', appType: 'linkwarden-collections' }, size: 24 },
{ node: { type: 'panel', id: 'linkwarden-links', appType: 'linkwarden-links' }, size: 76 },
],
}; };
+12 -9
View File
@@ -1,15 +1,18 @@
import type { AppRegistryMeta } from 'officerdev'; import type { AppRegistryMeta } from 'officerdev';
import { Bookmark } from 'lucide-react'; import { Bookmark, FolderTree } from 'lucide-react';
import { LinkwardenPanel } from './LinkwardenPanel'; import { CollectionsPanel } from './CollectionsPanel';
import { LinksPanel } from './LinksPanel';
// One panel, for now: where the instance is, and whether it answers. // Two panels: what you have saved, and how it is filed.
// //
// Reading and saving links happens in Linkwarden's own UI and in the browser extension, both of which // It was one — the settings form — and that was the mistake worth naming. A plugin whose only screen is
// are better at it than anything reproduced here would be. What Officer has to own is the part neither // its configuration is one you cannot leave: nothing is behind it, so the first thing anybody does after
// of those can do — pointing the install at a server, and saying who is who on it. // installing is look for the way forward and not find one. The settings are a disclosure inside the
// collections panel now, reachable when something is wrong and out of the way when it is not.
// //
// `availableOnPanel: false` keeps it out of the generic picker: it belongs to the /linkwarden screen // `availableOnPanel: false` keeps them out of the generic picker: they belong to the /linkwarden screen
// rather than being something to drop into an arbitrary dashboard. // rather than being things to drop into an arbitrary dashboard.
export const appRegistryMetas: AppRegistryMeta[] = [ export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'linkwarden-settings', name: 'Linkwarden', icon: Bookmark, component: LinkwardenPanel, availableOnPanel: false }, { key: 'linkwarden-collections', name: 'Collections', icon: FolderTree, component: CollectionsPanel, availableOnPanel: false },
{ key: 'linkwarden-links', name: 'Links', icon: Bookmark, component: LinksPanel, availableOnPanel: false },
]; ];