Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
186 lines
8.1 KiB
TypeScript
186 lines
8.1 KiB
TypeScript
/*
|
|
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 (`<ID>_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/<app>/history/`, and
|
|
the uninstall is refused if that cannot be done;
|
|
* every target's result is audited (the caller does that with `Audit`).
|
|
*/
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import type { HubConfig, HubServer } from './config.ts'
|
|
import type { UploadedFile } from './multipart.ts'
|
|
import { Upstream } from './upstream.ts'
|
|
|
|
export type StoreAction = 'install-store' | 'update-store' | 'uninstall'
|
|
export type FileAction = 'install-file' | 'update-file'
|
|
export type AppAction = StoreAction | FileAction
|
|
|
|
export interface TargetResult {
|
|
server: string
|
|
ok: boolean
|
|
message?: string
|
|
}
|
|
|
|
export const KEEP_HISTORY = 50
|
|
|
|
const API: Record<AppAction, string> = {
|
|
'install-store': 'apps/downloadAndInstall',
|
|
'update-store': 'apps/downloadAndUpdate',
|
|
uninstall: 'apps/uninstall',
|
|
'install-file': 'apps/install',
|
|
'update-file': 'apps/update',
|
|
}
|
|
|
|
const DONE: Record<AppAction, string> = {
|
|
'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.',
|
|
}
|
|
|
|
/** 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
|
|
}
|
|
|
|
/* A folder name for an app: letters, digits and . _ - only, with a short hash when anything had to change. */
|
|
export function safeName(name: string): string {
|
|
const cleaned = name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/\.{2,}/g, '_').replace(/^\.+/, '_').slice(0, 80)
|
|
if (cleaned === name && cleaned !== '') return cleaned
|
|
return `${cleaned === '' ? '_' : cleaned}-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
|
}
|
|
|
|
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<string | null> {
|
|
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): Promise<string> {
|
|
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 : ''
|
|
const dir = path.join(this.#config.dataDir, 'apps', safeName(name), 'history')
|
|
mkdirSync(dir, { recursive: true })
|
|
const stamp = new Date(this.#now()).toISOString().replace(/:/g, '-')
|
|
const file = path.join(dir, `${stamp}__${safeName(server.id)}__before-uninstall.txt`)
|
|
const temp = `${file}.${process.pid}.tmp`
|
|
writeFileSync(temp, text, { mode: 0o600 })
|
|
renameSync(temp, file)
|
|
// Oldest first by name (the name starts with the time); keep the newest KEEP_HISTORY.
|
|
const files = readdirSync(dir).filter((f) => f.endsWith('.txt')).sort()
|
|
for (const old of files.slice(0, Math.max(0, files.length - KEEP_HISTORY))) rmSync(path.join(dir, old), { force: true })
|
|
return file
|
|
}
|
|
|
|
/*
|
|
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): Promise<unknown> {
|
|
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 '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<string, string>()
|
|
const early = new Map<string, string>()
|
|
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<TargetResult> => {
|
|
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 }
|
|
}
|
|
}
|