Root location: an absolute path, with a picker that can make folders

defaults to `<owner home>/fofos`, and stores the FULL path — never the tilde. a
tilde means "whoever is asking", and the people asking will not all be the same
person: this plugin is grantable, and the site is in one place regardless of who
is looking at it. stored as `~/fofos` it would give every user a different answer
to a question that has one.

THE DEFAULT IS COMPUTED, NOT WRITTEN. no row means the default is worked out on
every read, so it follows the machine. writing it in at install freezes whichever
home existed on install day, and a restore onto a different machine then points at
a directory that is not there while looking deliberately chosen. a saved path is a
decision and is stored exactly as given; only its absence is derived. the form
says which of the two it is showing, because "not set" reading identically to "set
to the default" is how somebody comes to believe they configured something they
did not.

THE PICKER USES THE PLATFORM'S FILE API — /file-browser/ls and /mkdir — not routes
of this plugin's own. they already refuse to leave home, and they already tell a
missing directory from a forbidden one, which is a distinction that cost somebody
an afternoon and would have been re-guessed here. shipping a second listing and
mkdir would be a second filesystem surface to audit for the sake of one form.

that API is an execution capability and never grantable, so for anybody but the
owner it 403s — said in a sentence rather than shown as an empty tree, because an
empty listing is data and must never be how a refusal looks. it also browses the
owner's home and nothing above it; a path outside home is still reachable by
typing, since the picker fills the field and the FIELD is what saves. one value on
screen, one Save, and picking a folder commits nothing by itself.

"Use this folder" takes the one that is OPEN rather than one highlighted in the
list. that makes looking inside and choosing one gesture, and it is the only way
to choose a folder with nothing in it — which a freshly made one always is.

a SECOND table, not a column on fofos_admin_connection, whose url and token are
both notNull: a root path could not be saved there without also having an HTTP
connection, and that connection is being replaced rather than extended. when it
goes, its table is a clean drop instead of surgery on columns that outlived it.
the rule against tables in this plugin is unbent — what it forbids is the CLIENT'S
work, and this row holds a path somebody can retype.

needs `bun db:push`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 00:35:16 +01:00
co-authored by Claude Opus 5
parent ae9c30e53d
commit d5f2d8f370
7 changed files with 495 additions and 3 deletions
+32
View File
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router'; import { createRouter, type HonoVariables } from '@@/create-router';
import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { readSettings, saveRootPath, clearRootPath } from './settings';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
/** /**
@@ -153,6 +154,37 @@ router.post('/_config/test', async (ctx) => {
return ctx.json(result); return ctx.json(result);
}); });
/**
* Where the fofos site is on this machine.
*
* Under `/_config`, so the manifest's `ownerOnly: ['/_config']` covers it with no second rule to keep
* in step. That is the right level: this path is what the plugin will read and write the client's
* site through, so choosing it is an owner's decision even though editing the site is not.
*
* The caller's email only ever reaches `getOwnerHomeDir`, which ignores it and answers with the
* OWNER's home. That is deliberate rather than sloppy — see `api/settings.ts`.
*/
router.get('/_config/root', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await readSettings(user.email));
});
router.put('/_config/root', async (ctx) => {
const user = ctx.get('user');
const { path } = (ctx.get('body') ?? {}) as { path?: string };
try {
return ctx.json(await saveRootPath(path ?? '', user.email));
} catch (error) {
// The only failures here are the caller's own words being unusable, which is a 400 and not a fault.
return ctx.json({ error: (error as Error).message }, 400);
}
});
router.delete('/_config/root', async (ctx) => {
const user = ctx.get('user');
return ctx.json(await clearRootPath(user.email));
});
/** /**
* Is this plugin usable? Asked once by the detail panel rather than by all nine sections. * Is this plugin usable? Asked once by the detail panel rather than by all nine sections.
* *
+95
View File
@@ -0,0 +1,95 @@
import { isAbsolute, join, normalize } from 'node:path';
import { db } from 'officerdb/db';
import { eq } from 'drizzle-orm';
import { getOwnerHomeDir } from '@@/data-path';
import { fofosAdminSettings } from '../db/schema';
/**
* Administration's settings — currently one, where the fofos site is on this machine.
*
* ── The default is COMPUTED, not written ──
*
* No row means `<owner home>/fofos`, worked out on every read. The alternative — writing the default
* in at install — freezes whatever the home directory was on the day the plugin was installed, and a
* restore onto a machine with a different owner then points at a directory that does not exist while
* looking deliberately chosen. Computed, it follows the machine; written, it follows the install.
*
* A saved path is a DECISION and is stored exactly as given. Only the absence of one is derived.
*
* ── Absolute, always ──
*
* `~` is expanded here and never stored. It means "whoever is asking", and the people asking are not
* all the same person: this plugin is grantable, and the site is in one place regardless of who is
* looking at it. Storing the tilde would give every user a different answer to a question that has
* one.
*/
export type SettingsView = {
/** Absolute. Never null — an unset root reports its default rather than nothing. */
rootPath: string;
/** False while the default is standing in, so a form can show it as a suggestion, not a choice. */
isSet: boolean;
};
const row = async () => {
const rows = await db.select().from(fofosAdminSettings).limit(1);
return rows[0] ?? null;
};
/** `<owner home>/fofos`. The owner's, not the caller's — see the note on absoluteness above. */
export const defaultRootPath = (email: string): string => join(getOwnerHomeDir(email), 'fofos');
export async function readSettings(email: string): Promise<SettingsView> {
const found = await row();
const stored = found?.rootPath ?? null;
return { rootPath: stored ?? defaultRootPath(email), isSet: stored !== null };
}
/**
* Expand `~`, normalise, and refuse anything that is still not absolute.
*
* Refuses rather than resolving against a working directory: a relative path here would be resolved
* against whatever `process.cwd()` happened to be, which is the platform's checkout — a silently wrong
* answer that only shows up as an empty listing much later.
*/
export function normaliseRootPath(input: string, email: string): string {
const trimmed = input.trim();
if (!trimmed) throw new Error('A path is required.');
const home = getOwnerHomeDir(email);
const expanded = trimmed === '~' ? home : trimmed.startsWith('~/') ? join(home, trimmed.slice(2)) : trimmed;
if (!isAbsolute(expanded)) throw new Error('The root location must be an absolute path.');
// No trailing slash, so two spellings of the same directory cannot be stored as different values.
const cleaned = normalize(expanded).replace(/\/+$/, '');
return cleaned === '' ? '/' : cleaned;
}
export async function saveRootPath(input: string, email: string): Promise<SettingsView> {
const rootPath = normaliseRootPath(input, email);
const existing = await row();
if (existing) {
await db
.update(fofosAdminSettings)
.set({ rootPath, updatedAt: new Date() })
.where(eq(fofosAdminSettings.id, existing.id));
} else {
await db.insert(fofosAdminSettings).values({ rootPath });
}
return readSettings(email);
}
/** Back to the computed default. The row stays, so this is a reset rather than a delete. */
export async function clearRootPath(email: string): Promise<SettingsView> {
const existing = await row();
if (existing) {
await db
.update(fofosAdminSettings)
.set({ rootPath: null, updatedAt: new Date() })
.where(eq(fofosAdminSettings.id, existing.id));
}
return readSettings(email);
}
+34
View File
@@ -59,3 +59,37 @@ export const fofosAdminConnection = pgTable('fofos_admin_connection', {
}); });
export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect; export type FofosAdminConnection = typeof fofosAdminConnection.$inferSelect;
/**
* Administration's own settings. One row, and nothing in it belongs to the client.
*
* ── Why a second table and not a column on the one above ──
*
* `fofos_admin_connection.url` and `.token` are both `notNull`, so a root path could not be saved
* without also having an HTTP connection — and that connection is being replaced, not extended. When
* it goes, its table is a clean `drop` rather than surgery on columns that outlived it.
*
* The rule at the top of this file still holds and this does not bend it: what is forbidden here is
* the CLIENT'S WORK — collections, photographs, copy. This row holds a path and, later, how to read
* fofos's own database. Deleting it loses a setting somebody can retype, never a thing they made.
*/
export const fofosAdminSettings = pgTable('fofos_admin_settings', {
id: serial('id').primaryKey(),
/**
* Where the fofos site lives on this machine. ABSOLUTE, always.
*
* Never `~/fofos`. A tilde means "whoever is asking", and the people asking will not all be the same
* person — this plugin is grantable, and a member reading this setting must see the one real
* directory the site is in rather than a path that resolves to their own home and is not there.
*
* Null means never set, and the default is computed at read time rather than written here. See
* `api/settings.ts` for why that is not the same as writing it once at install.
*/
rootPath: text('root_path'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export type FofosAdminSettings = typeof fofosAdminSettings.$inferSelect;
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
version: '2026.08.29.06', version: '2026.08.29.07',
platform: '>=1.0.0', platform: '>=1.0.0',
label: 'Fofos', label: 'Fofos',
+2 -2
View File
@@ -1,4 +1,5 @@
import { Database, FolderRoot } from 'lucide-react'; import { Database, FolderRoot } from 'lucide-react';
import { RootLocationBlock } from './RootLocationBlock';
/** /**
* Administration — this plugin's own machinery, not the client's site. * Administration — this plugin's own machinery, not the client's site.
@@ -48,8 +49,7 @@ const Block = ({
export const AdministrationPanel = () => ( export const AdministrationPanel = () => (
<div className="flex size-full flex-col gap-4 overflow-auto p-5"> <div className="flex size-full flex-col gap-4 overflow-auto p-5">
<Block icon={FolderRoot} title="Root location" hint="Where the fofos site is on this machine."> <Block icon={FolderRoot} title="Root location" hint="Where the fofos site is on this machine.">
{/* Empty for the same reason as Database, below. */} <RootLocationBlock />
<p className="text-xs text-muted-foreground/70">Nothing to set yet.</p>
</Block> </Block>
<Block icon={Database} title="Database" hint="Where the fofos site's own data lives."> <Block icon={Database} title="Database" hint="Where the fofos site's own data lives.">
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronRight, CornerLeftUp, Folder, FolderPlus, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
/**
* Pick a directory, and make one if it is not there yet.
*
* ── It uses the PLATFORM's file API, not routes of this plugin's own ──
*
* `/api/file-browser/ls` and `/api/file-browser/mkdir` already exist, already refuse to leave the
* home directory, and already distinguish "missing" from "not allowed" — a distinction that cost
* somebody an afternoon to get right and would have been re-guessed here. A plugin that shipped its
* own listing and mkdir would be a second filesystem surface to audit for the sake of one form.
*
* The consequence is worth stating: this browses the OWNER'S HOME and nothing above it. The file API
* is an execution capability and never grantable, so for anybody but the owner these calls 403 — the
* component says so instead of showing an empty tree, because an empty listing is data and must never
* be how a refusal looks.
*
* A path outside home is still reachable by TYPING it in the field this sits under. The picker is a
* convenience over the common case, not the only way in.
*
* ── `rootDir` comes from the server, so the full path is real ──
*
* `ls` answers with the absolute `rootDir` it resolved as well as the relative path inside it. Joining
* those two is what lets this hand back an absolute path without the browser ever guessing at what
* home means — which it cannot know and would get wrong for anybody but the owner.
*/
type Entry = { name: string; type: 'directory' | 'file' };
type Listing = { path: string; rootDir?: string; entries: Entry[] };
/** `/a/b` under `/home/x` → `/home/x/a/b`. The root itself is `/`, which must not become a trailing slash. */
const absolute = (rootDir: string, path: string) => `${rootDir}${path === '/' ? '' : path}`.replace(/\/+$/, '') || '/';
export const DirectoryPicker = ({
startAt,
onPick,
onCancel,
}: {
/** Relative to home, e.g. `/fofos`. Where the browser opens. */
startAt?: string;
/** The chosen directory, ABSOLUTE. */
onPick: (absolutePath: string) => void;
onCancel: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [path, setPath] = useState(startAt ?? '/');
const [listing, setListing] = useState<Listing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const load = useCallback(async (target: string) => {
setLoading(true);
setError(null);
try {
const result = await clientRef.current.get<Listing>(
`/file-browser/ls?root=home&path=${encodeURIComponent(target)}`,
);
setListing(result);
// `ls` resets to the root when a path has gone, and reports where it actually ended up. Following
// its answer rather than our request is what stops a stale path showing the wrong folder's contents.
setPath(result.path);
} catch (failure) {
setError(readableError(failure));
setListing(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(startAt ?? '/');
}, [load, startAt]);
const rootDir = listing?.rootDir ?? '';
const directories = (listing?.entries ?? []).filter((entry) => entry.type === 'directory');
const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/';
const create = async () => {
const name = newName.trim();
if (!name) return;
setError(null);
try {
const target = path === '/' ? `/${name}` : `${path}/${name}`;
await clientRef.current.post('/file-browser/mkdir?root=home', { path: target });
setNewName('');
setCreating(false);
// Straight into it. Making a folder here means meaning to use it, and stopping at its parent
// makes somebody find what they just made in a list.
await load(target);
} catch (failure) {
setError(readableError(failure));
}
};
return (
<div className="flex flex-col gap-2 rounded-md border border-border">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{absolute(rootDir, path)}</span>
{loading && <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />}
</div>
<div className="max-h-56 overflow-auto px-1">
{parent !== null && (
<button
onClick={() => void load(parent)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<CornerLeftUp className="size-3.5 shrink-0 text-muted-foreground" />
Up one level
</button>
)}
{directories.map((entry) => (
<button
key={entry.name}
onClick={() => void load(path === '/' ? `/${entry.name}` : `${path}/${entry.name}`)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" />
</button>
))}
{/* Only when there is genuinely nothing, and worded as a fact about this folder. Files are not
listed at all — this picks a directory, and showing them would be a list of things that
cannot be chosen. */}
{!loading && !error && directories.length === 0 && (
<p className="px-2 py-2 text-xs text-muted-foreground">No folders here.</p>
)}
</div>
{error && <p className="px-3 text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
{creating ? (
<>
<Input
autoFocus
value={newName}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void create();
if (event.key === 'Escape') setCreating(false);
}}
placeholder="New folder name"
className="h-7 min-w-0 flex-1 text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button size="sm" className="h-7" onClick={() => void create()} disabled={!newName.trim()}>
Create
</Button>
<Button variant="ghost" size="sm" className="h-7" onClick={() => setCreating(false)}>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" className="h-7" onClick={() => setCreating(true)}>
<FolderPlus className="size-3.5" />
New folder
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7" onClick={onCancel}>
Cancel
</Button>
{/* Chooses the folder currently OPEN, not one highlighted in the list. Opening a folder to
look inside it and then choosing it is one gesture rather than two, and it is the only
way to choose a folder that has nothing in it yet — which a freshly made one always is. */}
<Button size="sm" className="h-7" disabled={!rootDir} onClick={() => onPick(absolute(rootDir, path))}>
Use this folder
</Button>
</>
)}
</div>
</div>
);
};
+140
View File
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, Loader2, RotateCcw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { readableError } from './errors';
import { DirectoryPicker } from './DirectoryPicker';
/**
* Where the fofos site is on this machine.
*
* ── The default is a suggestion until it is saved ──
*
* An unset root reports `<owner home>/fofos` and says so. It is not written to the database by being
* looked at — a value somebody has never agreed to must not become one they appear to have chosen, and
* a default that follows the machine is more useful than one frozen on install day.
*
* ── Typing and picking are the same field ──
*
* The picker fills the field; the field is what saves. So there is one value on screen and one Save,
* and picking a folder never commits anything by itself — the path is visible and editable before it
* is stored. It also means a location OUTSIDE the owner's home can still be typed, which the picker
* cannot reach.
*/
type Settings = { rootPath: string; isSet: boolean };
export const RootLocationBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [saved, setSaved] = useState<Settings | null>(null);
const [value, setValue] = useState('');
const [picking, setPicking] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [forbidden, setForbidden] = useState(false);
const load = useCallback(async () => {
try {
const current = await clientRef.current.get<Settings>('/fofos-admin/_config/root');
setSaved(current);
setValue(current.rootPath);
} catch (failure) {
// A member looking at an owner-only setting is not a fault and needs a different sentence.
if ((failure as { status?: number })?.status === 403) setForbidden(true);
else setError(readableError(failure));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.put<Settings>('/fofos-admin/_config/root', { path: value });
setSaved(result);
// From the server, not from what was typed — `~` is expanded and trailing slashes go, so the
// field must show what was actually stored rather than what was sent.
setValue(result.rootPath);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
const reset = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.delete<Settings>('/fofos-admin/_config/root');
setSaved(result);
setValue(result.rootPath);
} catch (failure) {
setError(readableError(failure));
} finally {
setBusy(false);
}
};
if (forbidden) {
return <p className="text-xs text-muted-foreground">Only the owner can change where the site lives.</p>;
}
if (!saved) {
return <p className="text-xs text-muted-foreground">Loading…</p>;
}
const dirty = value.trim() !== saved.rootPath;
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="/home/you/fofos"
className="min-w-0 flex-1 font-mono text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button variant="outline" size="sm" onClick={() => setPicking((open) => !open)}>
{picking ? 'Close' : 'Browse'}
</Button>
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty || !value.trim()}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save
</Button>
{saved.isSet && (
<Button variant="ghost" size="sm" onClick={() => void reset()} disabled={busy} title="Back to the default">
<RotateCcw className="size-3.5" />
</Button>
)}
</div>
{picking && (
<DirectoryPicker
onCancel={() => setPicking(false)}
onPick={(absolutePath) => {
setValue(absolutePath);
setPicking(false);
}}
/>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{/* Says which of the two states this is in. "Not set" reading identically to "set to exactly the
default" is how somebody comes to believe they configured something they did not. */}
<p className="text-xs text-muted-foreground/80">
{saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'}
</p>
</div>
);
};