The Backup step opened with an empty box and /mnt/usb/libreportal-backups as the placeholder — a path nobody has, presented as the shape of the answer. Someone rebuilding a server was being asked to recall from memory the one thing they came here because they had lost. Two additions, and the point of both is that neither needs the repository password. A restic repository keeps one file per snapshot under snapshots/, so "is there a backup here, and how many" is a directory listing. Nothing is decrypted — reading what is IN those snapshots is the next step, and that does need the password. restore scan looks where a backup actually is: this install's own backups root (the disk often survives), every location the install already knows about, and one level under each non-OS mount, a just-plugged-in drive being the other half of "the system drive died". Bounded to named shapes and maxdepth 1, never a filesystem walk — a scan nobody waits for is a scan nobody uses. Results are buttons, most snapshots first, each showing its count and the age of its newest snapshot; clicking one fills the path in. restore verify <path> answers the same for a typed path. Its most useful answer is the near-miss: pointing at the folder that CONTAINS the repositories rather than at one of them, which it names and offers as a button rather than explaining the distinction in prose. A repository is recognised by config plus the snapshots, keys and data directories together. config alone would match any folder with a file of that name, and offering a stray directory as someone's backup is worse than finding nothing. The placeholder now comes from this machine — the first repository found, or the install's own backups root — since a placeholder's job is to show the shape of the answer and only a real one does that. The backups root is in the storage feed for it. The found entries are buttons and had to own their geometry: .setup-app-card carries no layout, it is a bare wrapper elsewhere, so a <button> wearing it collapsed to one cramped line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
484 lines
19 KiB
JavaScript
484 lines
19 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';
|
|
|
|
try {
|
|
const id = await enqueueTask({
|
|
command: `libreportal restore rebuild ${idx} ${host || "''"} ${drop}`,
|
|
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;
|