Files
pastilhasandClaude Opus 5 944d3d9c5b a Media section, browsing the site's originals in place
a new section above Administration. it embeds THE PLATFORM'S OWN file browser rather
than drawing a file list — upload, delete, rename, move, thumbnails, the context
menu and every task hanging off it already exist and are maintained in one place. a
plugin's own would be a second to keep in step, arriving without any of that.

`basePath` IS A JAIL, not a starting folder: `useFileBrowserApp` resets currentPath
whenever it leaves, so pointing it at `<root>/media/originals` means the breadcrumb
stops there and `derived/` is not reachable by navigating. that is what was asked
for — people work in originals, derived is made for them.

IT IS A GUARDRAIL AND NOT A PERMISSION BOUNDARY, and the panel says so in its own
docstring because the difference will matter to whoever reads this next. the
file-browser API is unchanged and still addresses the whole home: anyone with file
access reaches derived/ through the Files screen. this prevents an accident, not a
determined person. confining it properly means scoping that API per user, which is a
platform change and a much larger one.

the panel also says, once, that derived/ exists and is made from these — otherwise
somebody who knows about it would think it had been lost.

`rootRelative` is new on the settings API because only the SERVER knows what home
is. the file browser addresses everything relative to a root, and a browser deriving
that prefix from the absolute path would be guessing. null when the root sits
outside home — a legitimate setting the browser cannot address, which the panel
explains rather than rendering an empty folder.

NOTHING HERE IS FOFOS-SPECIFIC, which matters now that this plugin is the template
for other site admins: the root is a setting, media/originals is the layout this
plugin creates, and both are read from the API. another site's plugin changes the
label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 08:39:53 +01:00

108 lines
4.6 KiB
TypeScript

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 — and
* once saved it stays saved: there is no route back to the default, because "reset this to something
* you never chose" is not a thing anybody asked for and it read as a second, quieter Save.
*
* ── 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;
/**
* The same place, relative to the owner's home, with a leading slash.
*
* The file browser addresses everything relative to a root — `home` — so a panel embedding it needs
* this form and cannot work it out: only the server knows what home is, and a browser computing it
* from the absolute path would be guessing at a prefix.
*
* Null when the root is outside home. That is a legitimate configuration — the root may be anywhere
* absolute — and it means the embedded browser cannot show it, which the panel says rather than
* rendering an empty folder.
*/
rootRelative: string | null;
/** 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;
const rootPath = stored ?? defaultRootPath(email);
// `home` or a path beneath it — anything else is outside what the file browser can address.
const home = getOwnerHomeDir(email);
const inside = rootPath === home || rootPath.startsWith(`${home}/`);
return {
rootPath,
rootRelative: inside ? `/${rootPath.slice(home.length).replace(/^\/+/, '')}`.replace(/\/$/, '') || '/' : null,
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);
}