// 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 {}; } } export class NextcloudClient { 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); 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; const adminPassword = cfg.password ?? 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. */ occ(args: string[]): string { try { return execFileSync("docker", ["exec", "-u", "www-data", this.container, "php", "occ", ...args], { encoding: "utf8", timeout: 60_000, }).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?`); } } 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 ?? {}) }; } /** List every share, over the OCS Sharing API with the admin credentials. */ async listShares(): Promise { const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64"); const res = await fetch( `${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`, { headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } }, ); if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`); 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", })); } }