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 — 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; }; /** `/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; 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 { 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); }