diff --git a/api/router.ts b/api/router.ts index bdbb3ef..846cc83 100644 --- a/api/router.ts +++ b/api/router.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono'; import { createRouter, type HonoVariables } from '@@/create-router'; import { registerProxiedPrefix } from '@@/sidecar/proxied-prefixes'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; +import { readSettings, saveRootPath, clearRootPath } from './settings'; import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client'; /** @@ -153,6 +154,37 @@ router.post('/_config/test', async (ctx) => { 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. * diff --git a/api/settings.ts b/api/settings.ts new file mode 100644 index 0000000..ba04e5e --- /dev/null +++ b/api/settings.ts @@ -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 `/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; +}; + +/** `/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 { + 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 { + 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 { + 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); +} diff --git a/db/schema.ts b/db/schema.ts index eab7cd8..fdccd4f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -59,3 +59,37 @@ export const fofosAdminConnection = pgTable('fofos_admin_connection', { }); 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; diff --git a/manifest.ts b/manifest.ts index 290e08a..4222128 100644 --- a/manifest.ts +++ b/manifest.ts @@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.29.06', + version: '2026.08.29.07', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/AdministrationPanel.tsx b/web/AdministrationPanel.tsx index 24b1c6f..9e99305 100644 --- a/web/AdministrationPanel.tsx +++ b/web/AdministrationPanel.tsx @@ -1,4 +1,5 @@ import { Database, FolderRoot } from 'lucide-react'; +import { RootLocationBlock } from './RootLocationBlock'; /** * Administration — this plugin's own machinery, not the client's site. @@ -48,8 +49,7 @@ const Block = ({ export const AdministrationPanel = () => (
- {/* Empty for the same reason as Database, below. */} -

Nothing to set yet.

+
diff --git a/web/DirectoryPicker.tsx b/web/DirectoryPicker.tsx new file mode 100644 index 0000000..e25f4ee --- /dev/null +++ b/web/DirectoryPicker.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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( + `/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 ( +
+
+ + {absolute(rootDir, path)} + {loading && } +
+ +
+ {parent !== null && ( + + )} + + {directories.map((entry) => ( + + ))} + + {/* 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 && ( +

No folders here.

+ )} +
+ + {error &&

{error}

} + +
+ {creating ? ( + <> + 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} + /> + + + + ) : ( + <> + +
+ + {/* 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. */} + + + )} +
+
+ ); +}; diff --git a/web/RootLocationBlock.tsx b/web/RootLocationBlock.tsx new file mode 100644 index 0000000..5b245ed --- /dev/null +++ b/web/RootLocationBlock.tsx @@ -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 `/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(null); + const [value, setValue] = useState(''); + const [picking, setPicking] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [forbidden, setForbidden] = useState(false); + + const load = useCallback(async () => { + try { + const current = await clientRef.current.get('/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('/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('/fofos-admin/_config/root'); + setSaved(result); + setValue(result.rootPath); + } catch (failure) { + setError(readableError(failure)); + } finally { + setBusy(false); + } + }; + + if (forbidden) { + return

Only the owner can change where the site lives.

; + } + + if (!saved) { + return

Loading…

; + } + + const dirty = value.trim() !== saved.rootPath; + + return ( +
+
+ setValue(event.target.value)} + placeholder="/home/you/fofos" + className="min-w-0 flex-1 font-mono text-xs" + autoComplete="off" + spellCheck={false} + /> + + + {saved.isSet && ( + + )} +
+ + {picking && ( + setPicking(false)} + onPick={(absolutePath) => { + setValue(absolutePath); + setPicking(false); + }} + /> + )} + + {error &&

{error}

} + + {/* 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. */} +

+ {saved.isSet ? 'Saved.' : 'Not set yet — this is the default until you save it.'} +

+
+ ); +};