occ's config:app:set put the secret on the docker exec command line, so it sat in the docker CLI's argv, the container's php argv and docker's exec record. Apply jwt_secret through config:import on occ's stdin instead; the other three onlyoffice keys still go through config:app:set.
202 lines
9.0 KiB
TypeScript
202 lines
9.0 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 {
|
|
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);
|
|
}
|
|
}
|
|
if (changed.length === 0) return { changed };
|
|
|
|
let checkOk = true;
|
|
try {
|
|
this.occ(["onlyoffice:documentserver", "--check"]);
|
|
} catch {
|
|
checkOk = false;
|
|
}
|
|
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",
|
|
}));
|
|
}
|
|
}
|