/* App writes: install, update and uninstall on one or several servers. The first part of the hub that changes anything, so the rules are strict: * only a server's ADMIN token writes (`_ADMIN_TOKEN`); a server without one is refused; * a store download URL is never taken from the client: it is looked up in the target server's own `apps/listStoreApps`, and a URL the client sends must match it; * before an uninstall the app's config is saved to `${HUB_DATA}/apps//history/`, and the uninstall is refused if that cannot be done; * every target's result is audited (the caller does that with `Audit`). */ import type { HubConfig, HubServer } from './config.ts' import type { UploadedFile } from './multipart.ts' import { MAX_CONFIG } from './apps-sync.ts' import { safeName, writeHistory } from './store.ts' import { Upstream } from './upstream.ts' export { safeName } export type StoreAction = 'install-store' | 'update-store' | 'uninstall' export type FileAction = 'install-file' | 'update-file' export type AppAction = StoreAction | FileAction | 'config-set' export interface TargetResult { server: string ok: boolean message?: string } const API: Record = { 'install-store': 'apps/downloadAndInstall', 'update-store': 'apps/downloadAndUpdate', uninstall: 'apps/uninstall', 'install-file': 'apps/install', 'update-file': 'apps/update', 'config-set': 'apps/config/set', } const DONE: Record = { 'install-store': 'Installed from the store.', 'update-store': 'Updated from the store.', uninstall: 'Uninstalled.', 'install-file': 'Installed from the file.', 'update-file': 'Updated from the file.', 'config-set': 'Config saved.', } /** The audit/api action for an upstream write path, or null when it is not an app write. */ export function actionForPath(apiPath: string): AppAction | null { for (const [action, p] of Object.entries(API)) if (p === apiPath) return action as AppAction return null } const reason = (e: unknown): string => (e instanceof Error ? e.message : 'failed') export class AppsWriter { #config: HubConfig #upstream: Upstream #now: () => number constructor(config: HubConfig, upstream: Upstream, now: () => number = Date.now) { this.#config = config this.#upstream = upstream this.#now = now } /** The ids of the servers that have an admin token. */ writableIds(): string[] { return this.#config.servers.filter((s) => s.adminToken !== undefined).map((s) => s.id) } /* The URL the server's own store gives for this app, or null. */ async storeUrl(server: HubServer, name: string): Promise { const response = (await this.#upstream.call(server, 'apps/listStoreApps')) as { storeApps?: { name: string; url?: string }[] } | null const found = (response?.storeApps ?? []).find((a) => a.name === name) return typeof found?.url === 'string' && found.url !== '' ? found.url : null } /* Saves the app's current config next to the hub's data; throws if it cannot. */ async saveHistory(server: HubServer, name: string, kind: 'before-uninstall' | 'before-set' = 'before-uninstall'): Promise { if (server.adminToken === undefined) throw new Error('no admin token configured') this.#upstream.invalidate(server.id) const response = (await this.#upstream.call(server, 'apps/config/get', { name }, server.adminToken)) as { config?: unknown } | null const text = typeof response?.config === 'string' ? response.config : '' return writeHistory(this.#config.dataDir, name, server.id, text, kind, this.#now()) } /* One write on one server. `url` must already be checked against the store; `file` is the zip for the two file actions. Returns the server's response, or throws. */ async perform(server: HubServer, action: AppAction, name: string, url?: string, file?: UploadedFile, config?: string): Promise { if (server.adminToken === undefined) throw new Error('no admin token configured') switch (action) { case 'install-store': case 'update-store': return await this.#upstream.write(server, API[action], { name, url: url ?? '' }) case 'uninstall': try { await this.saveHistory(server, name) } catch (e) { throw new Error(`the config could not be saved before the uninstall (${reason(e)}); nothing was uninstalled`) } return await this.#upstream.write(server, API[action], { name }) case 'config-set': { if (config === undefined || config.length > MAX_CONFIG) throw new Error('the config is missing or larger than 1 MB') try { await this.saveHistory(server, name, 'before-set') } catch (e) { throw new Error(`the old config could not be saved first (${reason(e)}); nothing was changed`) } return await this.#upstream.write(server, API[action], {}, new URLSearchParams({ name, config })) } case 'install-file': case 'update-file': { if (file === undefined) throw new Error('no zip file') const form = new FormData() form.append('fileAppZip', new Blob([file.data], { type: file.type }), file.filename) return await this.#upstream.write(server, API[action], { name }, form) } } } /* The same write on several servers, in parallel. A store action first resolves each target's URL from its own store; `suppliedUrl` (if the client sent one) has to match every one of them. Returns `{ error }` when the request itself is refused, else one result per target. */ async runMany( action: AppAction, name: string, targets: string[], options: { suppliedUrl?: string; file?: UploadedFile } = {}, ): Promise<{ error: string } | { results: TargetResult[] }> { const known = new Map(this.#config.servers.map((s) => [s.id, s])) const unique = [...new Set(targets)] if (name === '' || name.length > 200) return { error: 'An app name is required.' } if (unique.length === 0) return { error: 'Choose at least one server.' } const unknown = unique.filter((t) => !known.has(t)) if (unknown.length > 0) return { error: `Unknown server: ${unknown.join(', ')}.` } const urls = new Map() const early = new Map() if (action === 'install-store' || action === 'update-store') { await Promise.all( unique.map(async (id) => { const server = known.get(id) as HubServer if (server.adminToken === undefined) return early.set(id, 'no admin token configured') try { const url = await this.storeUrl(server, name) if (url === null) early.set(id, `"${name}" is not in this server's store listing`) else urls.set(id, url) } catch (e) { early.set(id, `the store listing could not be read (${reason(e)})`) } }), ) if (options.suppliedUrl !== undefined && options.suppliedUrl !== '' && [...urls.values()].some((u) => u !== options.suppliedUrl)) { return { error: 'The url is not from the store listing; the hub only installs what a server offers.' } } } const results = await Promise.all( unique.map(async (id): Promise => { const server = known.get(id) as HubServer const refused = early.get(id) ?? (server.adminToken === undefined ? 'no admin token configured' : undefined) if (refused !== undefined) return { server: id, ok: false, message: refused } try { await this.perform(server, action, name, urls.get(id), options.file) return { server: id, ok: true, message: DONE[action] } } catch (e) { return { server: id, ok: false, message: reason(e) } } }), ) return { results } } }