Files
mesh-catalog/modules/nextcloud/client.ts
T
jochen edb6d3b7d3 nextcloud: keep checking onlyoffice's connection while the last check failed (issue 1832)
applyOnlyOffice only ran the connection check when a setting changed, so
once it failed while settings matched, nothing checked again and the
failure was logged once and forgotten. The client now remembers whether
the last check failed and re-runs it on every tick until it passes;
index.ts logs every failing tick and one line when it recovers.
2026-10-22 16:28:35 +02:00

209 lines
9.4 KiB
TypeScript

// 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<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
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<string>();
function ignoreSecretSetting(cfg: Record<string, unknown>, 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<readonly [occKey: string, configKey: string]> = [
["DocumentServerUrl", "onlyofficeUrl"],
["DocumentServerInternalUrl", "onlyofficeInternalUrl"],
["StorageUrl", "onlyofficeStorageUrl"],
["jwt_secret", "onlyofficeSecret"],
];
let saidOnlyOfficeNotGiven = false;
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;
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;
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).
*/
occ(args: string[], opts: { input?: string } = {}): string {
const dockerArgs = opts.input !== undefined
? ["exec", "-i", "-u", "www-data", this.container, "php", "occ", ...args]
: ["exec", "-u", "www-data", this.container, "php", "occ", ...args];
try {
return execFileSync("docker", dockerArgs, {
encoding: "utf8", timeout: 60_000,
...(opts.input !== undefined ? { input: opts.input } : {}),
}).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<string, string>;
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<string, unknown>; disabled?: Record<string, unknown> };
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<NextcloudShare[]> {
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",
}));
}
}