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>
108 lines
4.6 KiB
TypeScript
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);
|
|
}
|