// Nextcloud's client — nextcloud's own code, living in the module (novox/hq ADR 0039). Both this // module's tools and its events entrypoint import it, and nothing outside nextcloud does. // // Nextcloud is administered two ways, and this client speaks both: // - occ, its admin CLI, is a PHP script inside the container runnable only as the web user. We // reach it with `docker exec`, the same side channel an operator would use by hand — turned // into something the mesh can call. Users and apps come from here. // - the OCS Sharing API answers over HTTP with the admin credentials. Shares come from here, // because occ has no version-stable "list every share" across the releases we run. import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; export interface NextcloudUser { uid: string; displayName: string; } export interface NextcloudShare { /** The OCS share id — the stable identity a new share is diffed on. */ id: string; path: string; shareType: number; shareWith?: string; owner: string; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } /** * A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of * them in the settings file is ignored, and that is said once — by its key, never with its value. */ const saidIgnored = new Set(); function ignoreSecretSetting(cfg: Record, key: string): void { if (cfg[key] === undefined || saidIgnored.has(key)) return; saidIgnored.add(key); console.error(`the setting ${key} is ignored: it comes from the vault`); } /** What applyOnlyOffice changed and whether it checked the connection (novox/hq issue 1830). */ export interface OnlyOfficeResult { /** The onlyoffice occ keys that were set this call — key names only, never a value. */ changed: string[]; /** Whether `onlyoffice:documentserver --check` ran, and passed; absent when nothing changed. */ checkOk?: boolean; } // occ's config key, and the mesh-delivered config.json key it comes from (files/config.json). const ONLYOFFICE_KEYS: ReadonlyArray = [ ["DocumentServerUrl", "onlyofficeUrl"], ["DocumentServerInternalUrl", "onlyofficeInternalUrl"], ["StorageUrl", "onlyofficeStorageUrl"], ["jwt_secret", "onlyofficeSecret"], ]; let saidOnlyOfficeNotGiven = false; /** * Nextcloud refused the mesh's own login on its OCS surface (HTTP 401), or throttled it after * refusals (HTTP 429, its brute-force protection). Said by its status and the account, never a * password; the events loop backs off on it instead of asking again every tick. */ export class LoginRefusedError extends Error { constructor(readonly status: number, readonly user: string, detail: string) { super(`Nextcloud refused the login of ${user} (${status}${detail ? ": " + detail : ""})`); this.name = "LoginRefusedError"; } } /** The OCS envelope's own message, the one line worth saying of a refusal; empty when none. */ function ocsMessage(body: string): string { const xml = /([^<]*)<\/message>/.exec(body); if (xml) return xml[1].trim(); try { return String(JSON.parse(body)?.ocs?.meta?.message ?? "").trim(); } catch { return ""; } } /** * How long to leave a failing poll alone: the base interval after the first failure, doubling on * each further one up to `maxMs`, and back to every tick once a poll passes. `due` says whether a * tick should poll; `failed` records a failure and answers whether it is the first of a run (said * loud) or the one that reached the ceiling (said once more), so a failure is never said every tick. */ export class Backoff { private delayMs = 0; private nextAt = 0; private failures = 0; constructor(private readonly baseMs: number, private readonly maxMs: number) {} due(now: number): boolean { return now >= this.nextAt; } failed(now: number): { first: boolean; atCeiling: boolean; delayMs: number } { this.failures += 1; const before = this.delayMs; this.delayMs = before === 0 ? this.baseMs : Math.min(before * 2, this.maxMs); this.nextAt = now + this.delayMs; return { first: this.failures === 1, atCeiling: this.delayMs === this.maxMs && before !== this.maxMs, delayMs: this.delayMs }; } /** A poll passed; answers whether it ended a run of failures. */ passed(): boolean { const wasFailing = this.failures > 0; this.failures = 0; this.delayMs = 0; this.nextAt = 0; return wasFailing; } } export class NextcloudClient { // Whether the last connection check failed (novox/hq issue 1832): while it did, every tick runs // the check again, even when nothing changed, until it passes. private lastCheckFailed = false; // Whether the account was already made right since the last accepted login (novox/hq issue // 1253): a refusal makes it right once, and a refusal after that is said, not repaired again. private accountMadeRight = false; constructor( private readonly container: string, private readonly ocsUrl: string, private readonly adminUser: string, private readonly adminPassword: string, ) {} /** * Build from the module's resolved environment. occ needs only the container name (default * "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret * (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local * container). The admin password is treated as the "configured for mesh administration" signal: * throws without it, and the module then contributes nothing rather than failing. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient { const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE); // An empty setting is the manifest's "empty is the default", never an empty name. const container = cfg.container || env.MESH_NEXTCLOUD_CONTAINER || "nextcloud"; const ocsUrl = cfg.url || env.MESH_NEXTCLOUD_URL || `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || "admin"; const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE; ignoreSecretSetting(cfg, "password"); const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined); if (!adminPassword) { throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE " + "(or MESH_NEXTCLOUD_ADMIN_PASSWORD)"); } return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); } /** * Run occ inside the container as the web user, returning its stdout, throwing its own message. * With `opts.input` given, occ reads it from stdin (`docker exec -i`) instead of taking it on the * command line — the only way a secret value reaches occ without sitting in the docker CLI's argv, * the container's php argv and docker's exec record (novox/hq issue 1831). With `opts.env` given, * each variable is passed by name alone (`docker exec -e NAME`), its value taken from the docker * CLI's own environment, so it is on no argv either (occ's `--password-from-env` reads OC_PASS). */ occ(args: string[], opts: { input?: string; env?: Record } = {}): string { const dockerArgs = ["exec"]; if (opts.input !== undefined) dockerArgs.push("-i"); for (const name of Object.keys(opts.env ?? {})) dockerArgs.push("-e", name); dockerArgs.push("-u", "www-data", this.container, "php", "occ", ...args); try { return execFileSync("docker", dockerArgs, { encoding: "utf8", timeout: 60_000, ...(opts.input !== undefined ? { input: opts.input } : {}), ...(opts.env ? { env: { ...process.env, ...opts.env } } : {}), }).trim(); } catch (err: any) { const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim(); throw new Error(detail || `occ produced no output — is the ${this.container} container running?`); } } /** * Applies OnlyOffice's settings from the mesh-delivered config (novox/hq issue 1830), read fresh * from the config file on every call. Sets only the occ keys that differ from Nextcloud's current * ones, then runs the connection check once something changed. When any of the four values is not * given yet (document-server not bound, or its secret not given), changes nothing and says so once * — never on every call. Never logs, returns or emits the secret's value: `changed` holds key names * only, `jwt_secret` among them, never jwt_secret's value. */ applyOnlyOffice(configFile: string | undefined = process.env.MESH_NEXTCLOUD_CONFIG_FILE): OnlyOfficeResult { const cfg = meshConfig(configFile); const desired = ONLYOFFICE_KEYS.map(([occKey, configKey]) => [occKey, cfg[configKey] ?? ""] as const); if (desired.some(([, value]) => !value)) { if (!saidOnlyOfficeNotGiven) { saidOnlyOfficeNotGiven = true; console.error("onlyoffice: document-server is not bound yet, or its secret is not given yet — nothing applied"); } return { changed: [] }; } const changed: string[] = []; for (const [occKey, value] of desired) { if (this.currentAppConfig(occKey) !== value) { // jwt_secret is a secret: apply it by stdin of config:import, never on an argv (issue 1831). if (occKey === "jwt_secret") { this.occ(["config:import"], { input: JSON.stringify({ apps: { onlyoffice: { jwt_secret: value } } }) }); } else { this.occ(["config:app:set", "onlyoffice", occKey, `--value=${value}`]); } changed.push(occKey); } } // Nothing changed, and the last check (if any) passed: there is nothing to check again // (novox/hq issue 1832). Otherwise — something changed, or the last check failed — run it. if (changed.length === 0 && !this.lastCheckFailed) return { changed }; let checkOk = true; try { this.occ(["onlyoffice:documentserver", "--check"]); } catch { checkOk = false; } this.lastCheckFailed = !checkOk; return { changed, checkOk }; } /** * config:app:get of a key onlyoffice never set can throw occ's own "produced no output" error * (occ above) rather than answer an empty string; read either as unset, so a first run sets it * rather than aborting the loop. */ private currentAppConfig(key: string): string { try { return this.occ(["config:app:get", "onlyoffice", key]); } catch { return ""; } } listUsers(): NextcloudUser[] { // user:list --output=json answers an object of uid → display name. const raw = this.occ(["user:list", "--output=json"]); const map = JSON.parse(raw || "{}") as Record; return Object.entries(map).map(([uid, displayName]) => ({ uid, displayName })); } listApps(): { enabled: string[]; disabled: string[] } { const raw = this.occ(["app:list", "--output=json"]); const parsed = JSON.parse(raw || "{}") as { enabled?: Record; disabled?: Record }; return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) }; } /** * Makes the mesh's own account in Nextcloud match the module's own secret (novox/hq issue 1253). * The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance * the mesh adopted never has it, and a secret made anew never reaches it. Through occ: the * account is added in the admin group when missing; otherwise its password is set to the secret * and it is put in the admin group. The password goes by OC_PASS (`--password-from-env`), never * on an argv. Answers what it did; throws occ's own message when occ fails. */ ensureAdminAccount(): "created" | "password-set" { const pass = { env: { OC_PASS: this.adminPassword } }; let exists = true; try { this.occ(["user:info", this.adminUser, "--output=json"]); } catch (err) { if (!/not found|does not exist/i.test(String((err as Error)?.message ?? err))) throw err; exists = false; } if (!exists) { this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", "--group=admin", this.adminUser], pass); return "created"; } this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass); try { this.occ(["group:adduser", "admin", this.adminUser]); } catch (err) { if (!/already/i.test(String((err as Error)?.message ?? err))) throw err; } return "password-set"; } private fetchShares(): Promise { const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); return fetch( `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, ); } /** * List every share, over the OCS Sharing API as the mesh's own account. A refused login (401) * makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or a * throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253). */ async listShares(): Promise { let res = await this.fetchShares(); if (res.status === 401 && !this.accountMadeRight) { await res.text(); this.accountMadeRight = true; this.ensureAdminAccount(); res = await this.fetchShares(); } if (res.status === 401 || res.status === 429) { throw new LoginRefusedError(res.status, this.adminUser, ocsMessage(await res.text())); } if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${ocsMessage(await res.text())}`.trim()); this.accountMadeRight = false; const rows = ((await res.json())?.ocs?.data ?? []) as any[]; return rows.map((s) => ({ id: String(s.id), path: s.path ?? "", shareType: Number(s.share_type ?? -1), shareWith: s.share_with || undefined, owner: s.uid_owner ?? "unknown", })); } } /** * Runs one poll of the shares under a back-off (novox/hq issue 1253): skipped while backing off; * a LoginRefusedError is said loud at the first failure and once more when the wait reaches its * ceiling, never on every tick; one line says when it passes again. Other errors are thrown on, * so the loop logs them as before. */ export async function pollSharesGuarded( client: NextcloudClient, backoff: Backoff, poll: (c: NextcloudClient) => Promise, now: () => number = Date.now, ): Promise { if (!backoff.due(now())) return; try { await poll(client); } catch (err) { if (!(err instanceof LoginRefusedError)) throw err; const step = backoff.failed(now()); const minutes = Math.round(step.delayMs / 60_000); if (step.first) { console.error(`[nextcloud] shares: ${err.message}; not asking again for ${minutes} min, then less often, up to every hour`); } else if (step.atCeiling) { console.error(`[nextcloud] shares: ${err.message}, still; asking once an hour from now on`); } return; } if (backoff.passed()) console.log("[nextcloud] shares: the login is accepted again"); }