import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { eq } from 'drizzle-orm'; import { fofosAdminConnection, fofosAdminSettings, type FofosAdminConnection } from '../db/schema'; import { normaliseUrl, type FofosCredentials } from './fofos-client'; /** * Reading and writing the one connection row. * * The token is encrypted under this plugin's own purpose key, so a leak of that key opens this column * and nothing else — the per-purpose split the platform's secret store already decided on. * * **The token never leaves this module in plaintext except into `fofosRequest`.** No route returns * it, at any permission level, to anybody, including the owner. A form that wants to show "a token is * set" gets a boolean. */ const PURPOSE = 'fofos-admin'; export type ConnectionView = { configured: boolean; url: string | null; /** Never the token itself. Only whether one is stored. */ hasToken: boolean; sections: string[] | null; lastProbeAt: string | null; lastProbeOk: boolean | null; }; const EMPTY: ConnectionView = { configured: false, url: null, hasToken: false, sections: null, lastProbeAt: null, lastProbeOk: null, }; const row = async (): Promise => { const rows = await db.select().from(fofosAdminConnection).limit(1); return rows[0] ?? null; }; export async function readConnection(): Promise { const found = await row(); if (!found) return EMPTY; return { configured: true, url: found.url, hasToken: found.token.length > 0, sections: found.sections ?? null, lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null, lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1, }; } /** Decrypted, for the client only. Never serialise the result of this. */ /** * How to reach the site's admin API — DERIVED now, not stored. * * ── Why there is nothing to configure ── * * This used to read a URL and a token somebody typed into a form. Both are now facts this plugin * already holds: the development server runs on a port it chose, and `FOFOS_API_TOKEN` is a secret it * generated and handed to that server at start. Asking a person to supply either would be asking them * to copy a value out of one part of this plugin and into another. * * ── Why DEVELOPMENT and not production ── * * Production is a directory of files behind nginx. It has no API, no database connection and nothing * listening — editing against it is not merely wrong, it is impossible. So the sections read and write * the development server, and Build is what publishes. That is the shape the site's own build script * already implies, and it means an edit is visible on dev immediately and public only when somebody * decides it should be. * * Null when there is no port or no secret yet, which the panel reports as not being set up. */ export async function readCredentials(): Promise { const settings = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; if (!settings?.devPort || !settings.siteApiToken) return null; return { url: `http://127.0.0.1:${settings.devPort}`, token: decryptSecret('fofos-admin', settings.siteApiToken), }; } export type SaveParams = { url: string; token?: string }; /** * `token` omitted means "keep the stored one". * * So a typo in the URL can be corrected without going to find the token again, and a settings form * never has to round-trip a secret in order to send it back. */ export async function saveConnection(params: SaveParams): Promise { const url = normaliseUrl(params.url); const existing = await row(); if (!existing && !params.token) { throw new Error('A token is required the first time the connection is set.'); } const token = params.token ? encryptSecret(PURPOSE, params.token) : (existing?.token as string); if (existing) { await db .update(fofosAdminConnection) .set({ url, token, updatedAt: new Date() }) .where(eq(fofosAdminConnection.id, existing.id)); } else { await db.insert(fofosAdminConnection).values({ url, token }); } return readConnection(); } export async function deleteConnection(): Promise { await db.delete(fofosAdminConnection); } export async function recordProbe(sections: string[] | null, ok: boolean): Promise { const existing = await row(); if (!existing) return; await db .update(fofosAdminConnection) .set({ sections, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 }) .where(eq(fofosAdminConnection.id, existing.id)); }