A snapshot is one app's data, or the settings tree — never a machine. A
four-snapshot repository is typically two apps plus two versions of the
settings, not four backups to pick between. So the choice belongs on Contents,
after unlocking, where each snapshot has a name and a date rather than being a
hash.
Every row with more than one snapshot gets a picker, defaulting to the newest.
A row with one shows its date as text: a dropdown holding a single entry is a
control that cannot be operated, and it makes a repository with one backup look
like it is hiding something.
The chain already supported this. restorePickSnapshot has always passed any
value that is not the string "latest" straight through as an id; nothing ever
offered the choice. What was missing:
- restoreInspect returns every snapshot per app and for the settings, not
just the newest.
- restoreFirstRunBulk reads an optional RESTORE_SNAPSHOT_CHOICE map instead
of hardcoding "latest". An associative array rather than an argument,
because the CLI wrapper pads argv to nine slots and a per-app map cannot
survive it; the map reaches the host as base64 JSON, validated at the route
against restic short ids and app names since both hit a command line.
- backupRestoreSystemConfig takes a snapshot AND a host.
That host was a real bug. It defaulted to this machine's install name, which is
right for "recover my own settings" and wrong for a rebuild — the snapshots
carry the name of the machine being rebuilt FROM. It surfaced the moment a
restore adopted a config with a different install name and the next lookup
found nothing at all.
Verified by restoring both settings snapshots and diffing: 28bedbb0 brings back
a config carrying example.com, cc5b6bcf one with no domains.
Two CSS traps on the picker: appearance stayed `auto`, so the browser painted
its own control and ignored the colours entirely while the computed styles
looked right; and a `background:` shorthand later in the rule silently reset the
background-image, wiping the arrow set three lines above it.
lp-restore-adopt-test asserted configs/* were mode 0755 and started failing on
configs/webui, which libreportal-ownership sets to 0751:container on purpose —
tighter, and perfectly traversable. It asserts "the container user can traverse
it" now. A test that pins an incidental number reports a regression every time
someone improves the thing it is watching.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
505 lines
20 KiB
JavaScript
505 lines
20 KiB
JavaScript
// Setup Wizard backend.
|
|
//
|
|
// Three sync GETs (status / suggest-name / dns-check) plus one async POST
|
|
// (save) that hands off to the host task system. The lock file lives at
|
|
// /app/frontend/data/.setup_complete — under the existing frontend bind-mount
|
|
// so the container can read it and the host's setupApply can write it
|
|
// without us having to add a new bind-mount to docker-compose.yml.
|
|
//
|
|
// Sync endpoints intentionally do NOT round-trip through the task daemon —
|
|
// suggest-name and dns-check are pure read-only operations that we
|
|
// reimplement in JS, so they return in <50ms instead of waiting for the next
|
|
// cron tick.
|
|
|
|
const express = require('express');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const dns = require('dns').promises;
|
|
const https = require('https');
|
|
const { requireAuth } = require('../utils/middleware.js');
|
|
const { pokeFifo } = require('../utils/fifo.js');
|
|
|
|
const router = express.Router();
|
|
|
|
const TASKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'data', 'tasks');
|
|
const FIFO_PATH = path.join(TASKS_DIR, '.queue.fifo');
|
|
const SETUP_LOCK_FILE = path.join(__dirname, '..', '..', 'frontend', 'data', '.setup_complete');
|
|
|
|
const ADJECTIVES = [
|
|
'Quantum', 'Neutrino', 'Photon', 'Plasma', 'Quasar', 'Pulsar', 'Tachyon',
|
|
'Boson', 'Fermion', 'Hadron', 'Gluon', 'Muon', 'Higgs', 'Entangled',
|
|
'Singular', 'Warped', 'Tunneling', 'Coherent', 'Superposed', 'Spectral',
|
|
'Orbital', 'Cosmic', 'Stellar', 'Nebular', 'Astral', 'Gravitic', 'Inertial',
|
|
'Relativistic', 'Helical', 'Toroidal', 'Holographic', 'Cryogenic',
|
|
'Crystalline', 'Resonant', 'Harmonic', 'Phasic', 'Drifting', 'Spinning',
|
|
'Pulsing', 'Hyper'
|
|
];
|
|
|
|
const NOUNS = [
|
|
'Frog', 'Fox', 'Otter', 'Raven', 'Wolf', 'Yak', 'Lynx', 'Owl', 'Hawk',
|
|
'Crow', 'Newt', 'Wren', 'Eel', 'Crab', 'Squid', 'Octopus', 'Mantis',
|
|
'Cobra', 'Viper', 'Ferret', 'Badger', 'Penguin', 'Panda', 'Lemur', 'Quark',
|
|
'Nebula', 'Comet', 'Nova', 'Eclipse', 'Aurora', 'Vortex', 'Helix', 'Halo',
|
|
'Phoenix', 'Hydra', 'Kraken', 'Sphinx', 'Specter', 'Phantom', 'Glyph'
|
|
];
|
|
|
|
function generateInstallName() {
|
|
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
|
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
|
return `${adj}${noun}`;
|
|
}
|
|
|
|
// Install order is enforced server-side. Monitoring goes first so apps
|
|
// installing later detect a live Prometheus/Grafana and wire their metrics
|
|
// export at install time — Traefik, CrowdSec et al. are monitoring consumers.
|
|
// Grafana follows Prometheus because its datasource points at it.
|
|
const INSTALL_TIERS = [
|
|
['prometheus', 'grafana'],
|
|
['traefik', 'crowdsec', 'trivy']
|
|
];
|
|
|
|
function sortAppsByTier(apps) {
|
|
const rank = new Map();
|
|
let r = 0;
|
|
for (const tier of INSTALL_TIERS) for (const slug of tier) rank.set(slug, r++);
|
|
return [...apps].sort((a, b) => {
|
|
const ra = rank.has(a) ? rank.get(a) : Infinity;
|
|
const rb = rank.has(b) ? rank.get(b) : Infinity;
|
|
if (ra !== rb) return ra - rb;
|
|
return apps.indexOf(a) - apps.indexOf(b);
|
|
});
|
|
}
|
|
|
|
function fetchPublicIp() {
|
|
return new Promise((resolve) => {
|
|
const req = https.get('https://api.ipify.org', { timeout: 3000 }, (res) => {
|
|
let body = '';
|
|
res.on('data', (chunk) => { body += chunk; });
|
|
res.on('end', () => resolve(body.trim() || null));
|
|
});
|
|
req.on('error', () => resolve(null));
|
|
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
});
|
|
}
|
|
|
|
router.get('/status', requireAuth, async (req, res) => {
|
|
const complete = fs.existsSync(SETUP_LOCK_FILE);
|
|
res.json({ complete });
|
|
});
|
|
|
|
router.get('/suggest-name', requireAuth, (req, res) => {
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json({ name: generateInstallName() });
|
|
});
|
|
|
|
router.get('/dns-check', requireAuth, async (req, res) => {
|
|
const domain = String(req.query.domain || '').trim().toLowerCase();
|
|
if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(domain)) {
|
|
return res.status(400).json({ matches: false, error: 'invalid domain' });
|
|
}
|
|
|
|
const [serverIp, domainIps] = await Promise.all([
|
|
fetchPublicIp(),
|
|
dns.resolve4(domain).catch(() => [])
|
|
]);
|
|
|
|
const domainIp = domainIps[0] || null;
|
|
const matches = !!(serverIp && domainIp && serverIp === domainIp);
|
|
|
|
res.json({ matches, server_ip: serverIp, domain_ip: domainIp });
|
|
});
|
|
|
|
// Each ticked app becomes its own `libreportal app install <name>` task —
|
|
// using the same task type the WebUI's app-install pipeline already
|
|
// understands, so the user sees individual progress per app instead of
|
|
// one opaque "setup apply" task. The first task writes the configs, the
|
|
// last marks the wizard complete; in between, the recommended apps run
|
|
// sequentially because the host daemon processes the FIFO in order.
|
|
async function enqueueTask(spec) {
|
|
const id = `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const task = {
|
|
id,
|
|
command: spec.command,
|
|
type: spec.type,
|
|
app: spec.app || 'libreportal',
|
|
config: spec.config || 'setup-wizard',
|
|
status: 'queued',
|
|
createdAt: new Date().toISOString(),
|
|
startedAt: null,
|
|
completedAt: null,
|
|
heartbeatAt: null,
|
|
exitCode: null,
|
|
errorMessage: null,
|
|
setupGroup: spec.setupGroup,
|
|
setupRole: spec.setupRole // 'config' | 'app' | 'finalize'
|
|
};
|
|
const taskPath = path.join(TASKS_DIR, `${id}.json`);
|
|
const tmp = `${taskPath}.tmp`;
|
|
await fsp.writeFile(tmp, JSON.stringify(task, null, 2));
|
|
await fsp.rename(tmp, taskPath);
|
|
pokeFifo(FIFO_PATH, id);
|
|
// Tiny stagger so each task gets a unique Date.now()-based id.
|
|
await new Promise(r => setTimeout(r, 2));
|
|
return id;
|
|
}
|
|
|
|
// Check a path full of .lpapp exports, without importing anything.
|
|
//
|
|
// Enqueues the host-side check and returns immediately; the result lands in
|
|
// frontend/data/system/import_check.json, which the wizard polls. Read-only,
|
|
// and a .lpapp is not encrypted, so no secret crosses this boundary — unlike a
|
|
// backup repository, which is why that one stays in the terminal installer.
|
|
// Hand a secret to the host without it ever reaching a command line.
|
|
//
|
|
// Everything else the wizard submits travels as part of a task's command
|
|
// string, which is recorded in frontend/data/tasks/*.json — 0644, inside a
|
|
// world-readable directory — and is visible in `ps` while the task runs. That
|
|
// is acceptable for a hostname; it is not for a backup repository password,
|
|
// which decrypts every backup the user has.
|
|
//
|
|
// So the value is written into a drop directory that root prepared for exactly
|
|
// this (see `libreportal-ownership secret-dir`): owned <container>:<manager>,
|
|
// mode 2730, so the setgid bit gives this file the manager's group and nobody
|
|
// else can read or even list it. The caller gets back an opaque reference and
|
|
// puts THAT in the task; the manager redeems it once, at the moment of the
|
|
// write, and unlinks it.
|
|
//
|
|
// The value is never logged, never echoed back, and never written anywhere
|
|
// else.
|
|
const SECRET_DIR = '/app/frontend/data/.secrets';
|
|
|
|
router.post('/secret', requireAuth, async (req, res) => {
|
|
const value = (req.body && typeof req.body.value === 'string') ? req.body.value : null;
|
|
if (value === null || value === '') {
|
|
return res.status(400).json({ error: 'A value is required' });
|
|
}
|
|
// Not a size limit for its own sake: this directory is readable by the
|
|
// manager, so it should never become somewhere to park arbitrary data.
|
|
if (Buffer.byteLength(value, 'utf8') > 4096) {
|
|
return res.status(413).json({ error: 'Value too large' });
|
|
}
|
|
|
|
try {
|
|
// Absent means the host has not run `libreportal-ownership secret-dir`.
|
|
// Creating it here would get the ownership wrong — only root can set
|
|
// <container>:<manager> — and a directory this container owned outright
|
|
// would not be readable by the manager, so fail loudly instead.
|
|
if (!fs.existsSync(SECRET_DIR)) {
|
|
return res.status(503).json({ error: 'Secret channel is not set up on this host' });
|
|
}
|
|
|
|
// Sweep before writing. A reference is redeemed once by the applier, but a
|
|
// flow the user abandons — closed the tab, hit a validation error, never
|
|
// pressed Save — leaves its secret behind, and these are repository
|
|
// passwords. webuiSecretSweep exists for this and had no callers at all,
|
|
// so nothing ever ran it; doing it here ties the cleanup to the one event
|
|
// that is guaranteed to happen whenever secrets are being created.
|
|
try {
|
|
const cutoff = Date.now() - 15 * 60 * 1000;
|
|
for (const name of await fsp.readdir(SECRET_DIR)) {
|
|
const f = path.join(SECRET_DIR, name);
|
|
const st = await fsp.stat(f).catch(() => null);
|
|
if (st && st.isFile() && st.mtimeMs < cutoff) await fsp.unlink(f).catch(() => {});
|
|
}
|
|
} catch { /* a sweep that fails must never block storing the new value */ }
|
|
|
|
const id = require('crypto').randomBytes(16).toString('hex');
|
|
const file = path.join(SECRET_DIR, id);
|
|
// 0640 explicitly rather than relying on the process umask: owner writes,
|
|
// the manager's group reads, nobody else.
|
|
await fsp.writeFile(file, value, { mode: 0o640, flag: 'wx' });
|
|
res.json({ ok: true, ref: `secret:${id}` });
|
|
} catch (e) {
|
|
// Deliberately not echoing the exception: it can contain the path, and on
|
|
// some failures the value.
|
|
res.status(500).json({ error: 'Could not store the value' });
|
|
}
|
|
});
|
|
|
|
router.post('/import-check', requireAuth, async (req, res) => {
|
|
const p = String((req.body && req.body.path) || '').trim();
|
|
if (!p || !p.startsWith('/')) {
|
|
return res.status(400).json({ error: 'An absolute path is required' });
|
|
}
|
|
// Shell-quote: this reaches a command line, and a path is user input.
|
|
const quoted = `'${p.replace(/'/g, "'\\''")}'`;
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal app import-check ${quoted} --publish`,
|
|
type: 'import',
|
|
app: 'libreportal',
|
|
setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
// Repositories already on this machine, and whether a given path is one.
|
|
//
|
|
// Neither needs the repository password: a restic repository keeps one file per
|
|
// snapshot under snapshots/, so "is this a backup, and how many" is a directory
|
|
// listing. Nothing is decrypted — reading what is IN the snapshots is the next
|
|
// step, and that does need the password.
|
|
router.post('/restore/scan', requireAuth, async (req, res) => {
|
|
const nonce = require('crypto').randomBytes(8).toString('hex');
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal restore scan --publish ${nonce}`,
|
|
type: 'restore', app: 'libreportal', setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id, nonce });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
router.post('/restore/verify', requireAuth, async (req, res) => {
|
|
const p = String((req.body && req.body.path) || '').trim();
|
|
if (!p.startsWith('/')) {
|
|
return res.status(400).json({ error: 'A full path is required' });
|
|
}
|
|
if (p.length > 1024) {
|
|
return res.status(413).json({ error: 'Path is too long' });
|
|
}
|
|
// Shell-quoted: this reaches a command line and the path is user input.
|
|
const quoted = `'${p.replace(/'/g, "'\\''")}'`;
|
|
const nonce = require('crypto').randomBytes(8).toString('hex');
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal restore verify ${quoted} --publish ${nonce}`,
|
|
type: 'restore', app: 'libreportal', setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id, nonce });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
// Read a backup repository: connect, list what is in it, and report. Nothing
|
|
// on this machine is written — the host creates a location to read through and
|
|
// removes it again if the read fails.
|
|
//
|
|
// The password never appears here. It arrives as a secret:<ref> the browser
|
|
// already handed to /secret, and is redeemed once, host-side, at the moment of
|
|
// the write. This payload reaches a task command line and tasks are recorded
|
|
// world-readable.
|
|
router.post('/restore/read', requireAuth, async (req, res) => {
|
|
const loc = (req.body && req.body.location) || null;
|
|
if (!loc || typeof loc !== 'object') {
|
|
return res.status(400).json({ error: 'A backup location is required' });
|
|
}
|
|
|
|
const TYPES = ['local', 'sftp', 'rest', 's3', 'b2'];
|
|
if (!TYPES.includes(String(loc.type || ''))) {
|
|
return res.status(400).json({ error: 'Unsupported backup type' });
|
|
}
|
|
if (loc.type === 'local' && !String(loc.path || '').startsWith('/')) {
|
|
return res.status(400).json({ error: 'A full path to the backup folder is required' });
|
|
}
|
|
// Only a reference may travel; a raw password in this field would end up in
|
|
// the task file, which is exactly what the secret channel exists to prevent.
|
|
if (loc.password_ref && !/^secret:[0-9a-f]{32}$/.test(String(loc.password_ref))) {
|
|
return res.status(400).json({ error: 'Invalid password reference' });
|
|
}
|
|
for (const k of Object.keys(loc)) {
|
|
if (typeof loc[k] === 'string' && loc[k].length > 1024) {
|
|
return res.status(413).json({ error: `${k} is too long` });
|
|
}
|
|
}
|
|
|
|
// base64 so the payload survives the command line intact — it carries paths
|
|
// and URLs, which are user input.
|
|
const b64 = Buffer.from(JSON.stringify({
|
|
location: loc,
|
|
host: typeof req.body.host === 'string' ? req.body.host : ''
|
|
}), 'utf8').toString('base64');
|
|
|
|
// A nonce echoed back in the published document, so the browser can tell its
|
|
// own answer from one left by an earlier attempt. Without it a second read
|
|
// shows the first read's repository — wrong in a way that looks plausible.
|
|
const nonce = require('crypto').randomBytes(8).toString('hex');
|
|
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal restore connect ${b64} --publish ${nonce}`,
|
|
type: 'restore',
|
|
app: 'libreportal',
|
|
setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id, nonce });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
// Run the rebuild: adopt settings, reconcile domains, restore apps. The
|
|
// location index comes from the read that preceded this, so the repository the
|
|
// user actually looked at is the one restored from.
|
|
router.post('/restore/apply', requireAuth, async (req, res) => {
|
|
const idx = String((req.body && req.body.location_idx) || '');
|
|
if (!/^[0-9]+$/.test(idx)) {
|
|
return res.status(400).json({ error: 'A backup location is required' });
|
|
}
|
|
// Host names come from the repository, but they still reach a command line.
|
|
const host = String((req.body && req.body.host) || '');
|
|
if (host && !/^[A-Za-z0-9._-]{1,64}$/.test(host)) {
|
|
return res.status(400).json({ error: 'Invalid host name' });
|
|
}
|
|
const drop = (req.body && req.body.drop_domains) ? 'yes' : 'no';
|
|
|
|
// Which snapshot of each thing. base64 JSON, because a per-app map cannot
|
|
// survive the CLI wrapper's nine positional slots. Validated first: these
|
|
// are restic short ids and app names, and both reach a command line.
|
|
let choice = '';
|
|
const c = req.body && req.body.choice;
|
|
if (c && typeof c === 'object') {
|
|
const clean = { system: null, apps: {}, times: {} };
|
|
if (typeof c.system === 'string' && /^[0-9a-f]{6,64}$/.test(c.system)) clean.system = c.system;
|
|
for (const [app, snap] of Object.entries(c.apps || {})) {
|
|
if (!/^[a-z0-9_-]+$/i.test(app)) continue;
|
|
if (typeof snap !== 'string' || !/^[0-9a-f]{6,64}$/.test(snap)) continue;
|
|
clean.apps[app] = snap;
|
|
}
|
|
for (const [app, when] of Object.entries(c.times || {})) {
|
|
if (!/^[a-z0-9_-]+$/i.test(app)) continue;
|
|
if (typeof when !== 'string' || when.length > 40) continue;
|
|
clean.times[app] = when;
|
|
}
|
|
choice = Buffer.from(JSON.stringify(clean), 'utf8').toString('base64');
|
|
}
|
|
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}${choice ? ' ' + choice : ''}`,
|
|
type: 'restore',
|
|
app: 'libreportal',
|
|
setupRole: 'config'
|
|
});
|
|
res.json({ ok: true, taskId: id });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message || String(e) });
|
|
}
|
|
});
|
|
|
|
router.post('/save', requireAuth, async (req, res) => {
|
|
const payload = req.body || {};
|
|
|
|
if (!payload.install_name || !/^[a-zA-Z0-9-]+$/.test(payload.install_name)) {
|
|
return res.status(400).json({ error: 'invalid install_name' });
|
|
}
|
|
if (!payload.timezone) {
|
|
return res.status(400).json({ error: 'timezone required' });
|
|
}
|
|
|
|
// Experience level seeds the WebUI's Beginner/Advanced UI mode default.
|
|
// Optional — old WebUIs may not send it — and constrained to the
|
|
// enum so a bad value can't smuggle anything into the bash applier.
|
|
if (payload.install_level !== undefined) {
|
|
if (payload.install_level !== 'beginner' && payload.install_level !== 'advanced') {
|
|
return res.status(400).json({ error: 'invalid install_level' });
|
|
}
|
|
}
|
|
|
|
// Domains are optional but each entry must be a valid hostname. Cap at
|
|
// 9 because the config schema only has CFG_DOMAIN_1..CFG_DOMAIN_9.
|
|
const domainRe = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
|
|
payload.domains = Array.isArray(payload.domains)
|
|
? payload.domains.map(d => String(d).trim().toLowerCase()).filter(Boolean)
|
|
: [];
|
|
if (payload.domains.length > 9) payload.domains = payload.domains.slice(0, 9);
|
|
for (const d of payload.domains) {
|
|
if (!domainRe.test(d)) return res.status(400).json({ error: `invalid domain: ${d}` });
|
|
}
|
|
|
|
payload.apps = Array.isArray(payload.apps) ? payload.apps.filter(a => /^[a-z0-9_-]+$/i.test(a)) : [];
|
|
payload.apps = sortAppsByTier(payload.apps);
|
|
|
|
// Validate appOptions — shape: { <appSlug>: { <optId>: bool, ... } }
|
|
const optsIn = (payload.appOptions && typeof payload.appOptions === 'object') ? payload.appOptions : {};
|
|
const safeOpts = {};
|
|
for (const [slug, opts] of Object.entries(optsIn)) {
|
|
if (!/^[a-z0-9_-]+$/i.test(slug)) continue;
|
|
if (!payload.apps.includes(slug)) continue;
|
|
if (!opts || typeof opts !== 'object') continue;
|
|
safeOpts[slug] = {};
|
|
for (const [k, v] of Object.entries(opts)) {
|
|
if (/^[a-z0-9_-]+$/i.test(k) && typeof v === 'boolean') safeOpts[slug][k] = v;
|
|
}
|
|
}
|
|
payload.appOptions = safeOpts;
|
|
|
|
const wantsTraefik = payload.apps.includes('traefik');
|
|
if (wantsTraefik) {
|
|
if (!payload.traefik_email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.traefik_email)) {
|
|
return res.status(400).json({ error: 'traefik_email required when installing Traefik' });
|
|
}
|
|
} else {
|
|
delete payload.traefik_email;
|
|
}
|
|
|
|
const setupGroup = `setup_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const b64 = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
|
|
try {
|
|
await fsp.mkdir(TASKS_DIR, { recursive: true });
|
|
const taskIds = [];
|
|
|
|
taskIds.push(await enqueueTask({
|
|
command: `libreportal setup config ${b64}`,
|
|
type: 'setup-config',
|
|
setupGroup,
|
|
setupRole: 'config'
|
|
}));
|
|
|
|
for (const appName of payload.apps) {
|
|
// Convert appOptions sub-flags into the framework's config_variables
|
|
// arg. Convention: sub-option <opt> on app <slug> maps to
|
|
// CFG_<SLUG>_<OPT>_ENABLED. dockerInstallApp parses these and writes
|
|
// them into the template config before calling install<App>.
|
|
let command = `libreportal app install ${appName}`;
|
|
const opts = payload.appOptions[appName] || {};
|
|
const cfgPairs = [];
|
|
const slugUpper = appName.toUpperCase().replace(/-/g, '_');
|
|
for (const [optId, value] of Object.entries(opts)) {
|
|
if (typeof value !== 'boolean') continue;
|
|
cfgPairs.push(`CFG_${slugUpper}_${optId.toUpperCase()}_ENABLED=${value}`);
|
|
}
|
|
if (cfgPairs.length) command += ` ${cfgPairs.join('|')}`;
|
|
|
|
taskIds.push(await enqueueTask({
|
|
command,
|
|
type: 'app-install',
|
|
app: appName,
|
|
setupGroup,
|
|
setupRole: 'app'
|
|
}));
|
|
}
|
|
|
|
const finalizeId = await enqueueTask({
|
|
// Pass the group id so finalize can inspect this run's app-install tasks
|
|
// and report whether every selected app actually installed.
|
|
command: `libreportal setup finalize ${setupGroup}`,
|
|
type: 'setup-finalize',
|
|
setupGroup,
|
|
setupRole: 'finalize'
|
|
});
|
|
taskIds.push(finalizeId);
|
|
|
|
res.status(201).json({
|
|
setupGroup,
|
|
taskIds,
|
|
firstTaskId: taskIds[0],
|
|
finalizeTaskId: finalizeId,
|
|
installName: payload.install_name
|
|
});
|
|
} catch (err) {
|
|
console.error('[setup] save failed:', err);
|
|
res.status(500).json({ error: 'failed to enqueue setup tasks' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|