The Services tab restart button POSTed to a backend endpoint that (a) checked the app's compose path from INSIDE the webui container, where the host's containers root isn't mounted — so every restart failed with 'Compose file not found' — and (b) queued a raw 'docker compose restart' that the host task processor would run as the manager user, which can't talk to the rootless daemon anyway. Errors surfaced via a bare alert(). Per-service restart now follows the exact shape of the whole-app verbs: - CLI: 'libreportal app restart <app> [service]' — the optional service arg makes dockerRestartApp restart just that compose service, via dockerCommandRun (right user in rootless mode) from the app dir on the host, where the compose file actually lives. Service names validated against compose-legal characters before touching a shell line. - WebUI: the button dispatches a 'service_restart' task action through the task router (mutations-via-tasks), runs in the background with the standard task toast + link — no page switch — and failures use the notification system instead of alert(). Because the task runs host- side, restarting the WebUI's own libreportal-service now works too. - Backend: the mutating restart endpoint and its now-unused helpers are removed; service-routes.js is read-only surface (status + log tails). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
516 lines
20 KiB
JavaScript
516 lines
20 KiB
JavaScript
// Per-app service routes.
|
|
//
|
|
// The "Services" tab on the app page asks: for app X, what compose
|
|
// services are defined, are they running, and how long have they been
|
|
// up — plus give me a restart button and a live log tail.
|
|
//
|
|
// Implementation notes:
|
|
// - The libreportal-service container does NOT have the `docker` CLI
|
|
// installed; it only has the docker socket bind-mounted. So instead
|
|
// of shelling out to `docker`, we talk to the Docker Engine HTTP API
|
|
// directly over the unix socket. That means no extra system deps and
|
|
// no group-level privilege grants — node only sees what the mounted
|
|
// socket lets it see.
|
|
// - This file is READ-ONLY surface (status + log tails). Restarting a
|
|
// service is a mutation, so it goes through the task system like every
|
|
// other mutation: the Services tab dispatches a `service_restart` task
|
|
// (core/tasks) that runs `libreportal app restart <app> <service>` on
|
|
// the host, where `docker` IS available and runs as the right user.
|
|
// - URLs / port chips for each service are read client-side from the
|
|
// existing /data/apps/generated/apps-services.json — no backend
|
|
// surface needed for that.
|
|
|
|
const express = require('express');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const { spawn } = require('child_process');
|
|
const { requireAuth } = require('../utils/middleware.js');
|
|
const { fileConfig } = require('../utils/config.js');
|
|
|
|
const router = express.Router();
|
|
|
|
const APPS_SERVICES_JSON = path.join(__dirname, '..', '..', 'frontend', 'data', 'apps', 'generated', 'apps-services.json');
|
|
|
|
// =====================================================================
|
|
// Docker socket discovery
|
|
// =====================================================================
|
|
// Whichever socket the host bind-mounted into us — that's the one we
|
|
// can reach. Rooted installs mount /var/run/docker.sock; rootless mounts
|
|
// /run/user/<uid>/docker.sock. No fallback to a docker group, no sudo,
|
|
// no daemon auth tokens — just the unix socket the host already chose
|
|
// to expose.
|
|
function detectDockerSocket() {
|
|
if (fs.existsSync('/var/run/docker.sock')) return '/var/run/docker.sock';
|
|
try {
|
|
for (const entry of fs.readdirSync('/run/user', { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const sock = `/run/user/${entry.name}/docker.sock`;
|
|
if (fs.existsSync(sock)) return sock;
|
|
}
|
|
} catch { /* /run/user not readable — that's fine */ }
|
|
return null;
|
|
}
|
|
|
|
const DOCKER_SOCKET = detectDockerSocket();
|
|
console.log(
|
|
DOCKER_SOCKET
|
|
? `[services] Docker API socket: ${DOCKER_SOCKET}`
|
|
: '[services] WARNING: no docker socket found — services tab will be empty'
|
|
);
|
|
|
|
// =====================================================================
|
|
// Tiny Docker HTTP API client
|
|
// =====================================================================
|
|
// The Docker daemon speaks HTTP/1.1 over a unix socket. Versioning is
|
|
// pinned to v1.41 (Docker 20.10+, far older than anything this project
|
|
// supports).
|
|
const DOCKER_API_VERSION = 'v1.41';
|
|
|
|
function dockerRequest(method, pathname, query) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
const req = http.request(
|
|
{
|
|
socketPath: DOCKER_SOCKET,
|
|
method,
|
|
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
headers: { 'Host': 'docker', 'Accept': 'application/json' }
|
|
},
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
try { resolve(body ? JSON.parse(body) : null); }
|
|
catch (e) { reject(new Error(`Docker API parse error: ${e.message}`)); }
|
|
} else {
|
|
reject(new Error(`Docker API ${res.statusCode}: ${body}`));
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Streaming GET — caller gets the raw IncomingMessage so they can pipe
|
|
// or parse the multiplexed log frames.
|
|
function dockerStream(pathname, query) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!DOCKER_SOCKET) return reject(new Error('No docker socket available'));
|
|
const qs = query ? '?' + new URLSearchParams(query).toString() : '';
|
|
const req = http.request(
|
|
{
|
|
socketPath: DOCKER_SOCKET,
|
|
method: 'GET',
|
|
path: `/${DOCKER_API_VERSION}${pathname}${qs}`,
|
|
headers: { 'Host': 'docker' }
|
|
},
|
|
(res) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
resolve({ stream: res, req });
|
|
} else {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => reject(new Error(
|
|
`Docker API ${res.statusCode}: ${Buffer.concat(chunks).toString('utf8')}`
|
|
)));
|
|
}
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Map Docker's verbose state info to a UX-friendly status line.
|
|
// running → "Up 2 hours"
|
|
// exited → "Exited (0) 5 minutes ago"
|
|
// restarting→ "Restarting"
|
|
function statusLineFromContainer(c) {
|
|
// `Status` from /containers/json is already exactly the human form
|
|
// we want ("Up 4 minutes", "Exited (0) 2 hours ago", etc.).
|
|
return c.Status || c.State || '';
|
|
}
|
|
|
|
// =====================================================================
|
|
// Validation helpers
|
|
// =====================================================================
|
|
const SAFE_NAME = /^[a-zA-Z0-9_.-]+$/;
|
|
function safeName(name) { return typeof name === 'string' && SAFE_NAME.test(name); }
|
|
|
|
// SSE-wrap `tail -F -n <tail> <file>` and emit `log` events line-by-line so
|
|
// the frontend renders host logs through the existing viewer with zero
|
|
// changes. We use file-based tailing instead of journalctl because the
|
|
// libreportal container is Alpine-based and journalctl plumbing into a
|
|
// non-systemd container is heavier than the value. CrowdSec writes
|
|
// /var/log/crowdsec.log and /var/log/crowdsec-firewall-bouncer.log by
|
|
// default — the libreportal compose bind-mounts /var/log:/host/var/log:ro
|
|
// so log paths in apps-services.json carry the /host prefix.
|
|
//
|
|
// -F (not -f): retries on missing files and follows log rotation, so a
|
|
// briefly-absent file (e.g., before the agent has started) doesn't kill the
|
|
// stream.
|
|
// Stream bounds — keep tail from forking forever and a chatty log from
|
|
// drowning the SSE channel. All three are user-configurable via
|
|
// configs/webui/webui_logs; 0 disables the limit (max-duration is the only
|
|
// one where 0 is dangerous — left to the operator's judgement).
|
|
function streamLimitsFromConfig() {
|
|
const idleMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES);
|
|
const maxMin = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES);
|
|
const lps = Number(fileConfig.CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC);
|
|
return {
|
|
idleMs: Number.isFinite(idleMin) && idleMin >= 0 ? idleMin * 60_000 : 10 * 60_000,
|
|
maxMs: Number.isFinite(maxMin) && maxMin >= 0 ? maxMin * 60_000 : 60 * 60_000,
|
|
maxLps: Number.isFinite(lps) && lps > 0 ? lps : 200
|
|
};
|
|
}
|
|
|
|
function streamHostLogFile(unit, logFile, tail, res, send, ping) {
|
|
// Whitelist: paths must live under the bind-mounted /host/var/log/ tree
|
|
// to prevent a malformed apps-services.json from reading anywhere on
|
|
// disk. apps-services.json itself is generator-produced, but defence in
|
|
// depth.
|
|
if (typeof logFile !== 'string' || !logFile.startsWith('/host/var/log/') || logFile.includes('..')) {
|
|
send('error', { message: `Refusing to tail untrusted log path: ${logFile}` });
|
|
send('end', { code: 400 });
|
|
clearInterval(ping);
|
|
return res.end();
|
|
}
|
|
const limits = streamLimitsFromConfig();
|
|
send('ready', {
|
|
at: Date.now(), tail, transport: 'systemd', unit, logFile,
|
|
limits: { idleMinutes: limits.idleMs / 60000, maxMinutes: limits.maxMs / 60000, maxLinesPerSec: limits.maxLps }
|
|
});
|
|
|
|
const child = spawn('tail', ['-F', '-n', String(tail), logFile], {
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
});
|
|
|
|
// Resource ceilings. cleanup() unwinds everything; called from req-close,
|
|
// tail-exit, hard-cap timeout, and idle-disconnect path.
|
|
let lastLineAt = Date.now();
|
|
let rateWindowStart = Date.now();
|
|
let rateWindowLines = 0;
|
|
let rateDroppedThisWindow = 0;
|
|
|
|
// 0 = disabled — skip the timer entirely.
|
|
const hardCapTimer = limits.maxMs > 0 ? setTimeout(() => {
|
|
send('end', { code: 0, reason: 'max-duration', limitMinutes: limits.maxMs / 60000 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
}, limits.maxMs) : null;
|
|
|
|
const idleTimer = limits.idleMs > 0 ? setInterval(() => {
|
|
if (Date.now() - lastLineAt > limits.idleMs) {
|
|
send('end', { code: 0, reason: 'idle-timeout', limitMinutes: limits.idleMs / 60000 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
}
|
|
}, 60_000) : null;
|
|
|
|
const cleanup = () => {
|
|
clearInterval(ping);
|
|
if (idleTimer) clearInterval(idleTimer);
|
|
if (hardCapTimer) clearTimeout(hardCapTimer);
|
|
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
|
};
|
|
res.req.on('close', cleanup);
|
|
|
|
// stdout = log lines; stderr usually = "cannot open" notices from tail
|
|
// when the file doesn't exist yet — surface as `log` lines too so the
|
|
// user sees what's happening without panicking the viewer.
|
|
const linebuf = (which) => {
|
|
let buf = '';
|
|
return (chunk) => {
|
|
buf += chunk.toString('utf8');
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop();
|
|
if (!lines.length) return;
|
|
lastLineAt = Date.now();
|
|
|
|
// Rate limit: rolling 1-second window. Lines past the ceiling drop;
|
|
// emit a single notice at window-close so the user knows a flood is
|
|
// ongoing without us spamming the notice line every iteration.
|
|
const now = Date.now();
|
|
if (now - rateWindowStart >= 1000) {
|
|
if (rateDroppedThisWindow > 0) {
|
|
send('log', { stream: 'meta', lines: [`[rate-limit: ${rateDroppedThisWindow} line(s) dropped in the last second]`] });
|
|
}
|
|
rateWindowStart = now;
|
|
rateWindowLines = 0;
|
|
rateDroppedThisWindow = 0;
|
|
}
|
|
const remaining = limits.maxLps - rateWindowLines;
|
|
if (remaining <= 0) {
|
|
rateDroppedThisWindow += lines.length;
|
|
return;
|
|
}
|
|
if (lines.length > remaining) {
|
|
send('log', { stream: which, lines: lines.slice(0, remaining) });
|
|
rateDroppedThisWindow += lines.length - remaining;
|
|
rateWindowLines = limits.maxLps;
|
|
} else {
|
|
send('log', { stream: which, lines });
|
|
rateWindowLines += lines.length;
|
|
}
|
|
};
|
|
};
|
|
child.stdout.on('data', linebuf('stdout'));
|
|
child.stderr.on('data', linebuf('stderr'));
|
|
|
|
child.on('error', (err) => {
|
|
send('error', { message: `tail spawn failed: ${err.message}` });
|
|
send('end', { code: 1 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
});
|
|
child.on('exit', (code) => {
|
|
send('end', { code: code ?? 0 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
});
|
|
}
|
|
|
|
// Look up a service entry in apps-services.json (the generator-produced file
|
|
// the frontend already consumes). Host-installed apps are emitted by
|
|
// webui_services.sh with `transport: 'systemd'` and a `unit` field — that's
|
|
// our signal to route logs to journalctl instead of `docker logs`.
|
|
//
|
|
// The lookup also doubles as an allow-list: we ONLY journalctl units that
|
|
// appear in this file, so a caller can't request `journalctl -u
|
|
// arbitrary.service`. The names there originate from CFG_*_HOST_SERVICES
|
|
// declared in container configs.
|
|
async function lookupServiceTransport(appName, serviceName) {
|
|
try {
|
|
const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
|
|
const data = JSON.parse(raw);
|
|
const entries = Array.isArray(data?.services) ? data.services : [];
|
|
for (const s of entries) {
|
|
if (s.app !== appName) continue;
|
|
if (s.serviceName !== serviceName && s.name !== serviceName) continue;
|
|
if (s.transport === 'systemd' && typeof s.unit === 'string') {
|
|
return { transport: 'systemd', unit: s.unit, logFile: s.logFile || null };
|
|
}
|
|
return { transport: 'docker' };
|
|
}
|
|
} catch { /* fall through to docker default */ }
|
|
return { transport: 'docker' };
|
|
}
|
|
|
|
// =====================================================================
|
|
// GET /api/apps/:appName/services/status
|
|
// → [{ serviceName, state, statusText, containerName, containerId }]
|
|
// =====================================================================
|
|
router.get('/:appName/services/status', requireAuth, async (req, res) => {
|
|
const { appName } = req.params;
|
|
if (!safeName(appName)) return res.status(400).json({ error: 'Invalid app name' });
|
|
|
|
try {
|
|
const filters = JSON.stringify({
|
|
label: [`com.docker.compose.project=${appName}`]
|
|
});
|
|
const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });
|
|
|
|
const services = (containers || [])
|
|
.map(c => {
|
|
const labels = c.Labels || {};
|
|
const serviceName = labels['com.docker.compose.service'];
|
|
if (!serviceName) return null;
|
|
// c.Names is like ['/libreportal-service'] — strip leading slash.
|
|
const containerName = (c.Names && c.Names[0] || '').replace(/^\//, '');
|
|
return {
|
|
serviceName,
|
|
state: c.State || 'unknown',
|
|
statusText: statusLineFromContainer(c),
|
|
containerName,
|
|
containerId: c.Id
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
// Merge in synthetic host-service entries from apps-services.json.
|
|
// webui_services.sh emits transport=systemd rows for HOST_INSTALL apps;
|
|
// they don't appear in Docker but should still render on the Services
|
|
// tab so the user can see status + tail logs for the host agent(s).
|
|
try {
|
|
const raw = await fsp.readFile(APPS_SERVICES_JSON, 'utf8');
|
|
const data = JSON.parse(raw);
|
|
for (const s of (data?.services || [])) {
|
|
if (s.app !== appName) continue;
|
|
if (s.transport !== 'systemd') continue;
|
|
services.push({
|
|
serviceName: s.serviceName || s.name,
|
|
state: s.status === 'active' ? 'running' : 'exited',
|
|
statusText: s.status === 'active' ? 'Active (host service)' : 'Inactive (host service)',
|
|
containerName: s.unit || s.serviceName,
|
|
containerId: null,
|
|
transport: 'systemd',
|
|
unit: s.unit
|
|
});
|
|
}
|
|
} catch { /* file may not exist yet on fresh install */ }
|
|
|
|
res.json(services);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// GET /api/apps/:appName/services/:serviceName/logs
|
|
// SSE-wraps the Docker /containers/<id>/logs?follow=1 stream.
|
|
// Docker multiplexes stdout+stderr into 8-byte-framed chunks unless
|
|
// the container has tty=true; we handle both.
|
|
// =====================================================================
|
|
router.get('/:appName/services/:serviceName/logs', requireAuth, async (req, res) => {
|
|
const { appName, serviceName } = req.params;
|
|
if (!safeName(appName) || !safeName(serviceName)) {
|
|
return res.status(400).json({ error: 'Invalid app or service name' });
|
|
}
|
|
const tail = Math.max(1, Math.min(2000, parseInt(req.query.tail, 10) || 200));
|
|
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
res.flushHeaders?.();
|
|
|
|
const send = (event, data) => {
|
|
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
|
|
};
|
|
|
|
// Heartbeat for reverse proxies during quiet logs.
|
|
const ping = setInterval(() => {
|
|
try { res.write(': ping\n\n'); } catch { /* gone */ }
|
|
}, 25_000);
|
|
|
|
// Fork: host-installed services (transport=systemd) get journalctl
|
|
// instead of `docker logs`. Lookup is via apps-services.json which also
|
|
// doubles as the unit-name allow-list — only units declared in
|
|
// CFG_*_HOST_SERVICES make it into that file.
|
|
const transport = await lookupServiceTransport(appName, serviceName);
|
|
if (transport.transport === 'systemd') {
|
|
if (!transport.logFile) {
|
|
send('error', { message: `Host service ${transport.unit} has no logFile configured.` });
|
|
send('end', { code: 404 });
|
|
clearInterval(ping);
|
|
return res.end();
|
|
}
|
|
return streamHostLogFile(transport.unit, transport.logFile, tail, res, send, ping);
|
|
}
|
|
|
|
let containerInspect, logStreamHandle;
|
|
|
|
const cleanup = () => {
|
|
clearInterval(ping);
|
|
try { logStreamHandle?.req.destroy(); } catch { /* already gone */ }
|
|
};
|
|
req.on('close', cleanup);
|
|
|
|
try {
|
|
// 1. Resolve the container that owns this compose service.
|
|
const filters = JSON.stringify({
|
|
label: [
|
|
`com.docker.compose.project=${appName}`,
|
|
`com.docker.compose.service=${serviceName}`
|
|
]
|
|
});
|
|
const containers = await dockerRequest('GET', '/containers/json', { all: '1', filters });
|
|
if (!containers || containers.length === 0) {
|
|
send('error', { message: `No container found for ${appName}/${serviceName}` });
|
|
send('end', { code: 404 });
|
|
cleanup();
|
|
return res.end();
|
|
}
|
|
const containerId = containers[0].Id;
|
|
|
|
// 2. Inspect once to learn whether the container has a TTY (changes
|
|
// how the log stream is framed).
|
|
containerInspect = await dockerRequest('GET', `/containers/${containerId}/json`);
|
|
const hasTty = !!(containerInspect.Config && containerInspect.Config.Tty);
|
|
|
|
send('ready', { at: Date.now(), tail, tty: hasTty });
|
|
|
|
// 3. Open the log stream.
|
|
logStreamHandle = await dockerStream(`/containers/${containerId}/logs`, {
|
|
stdout: '1',
|
|
stderr: '1',
|
|
follow: '1',
|
|
tail: String(tail),
|
|
timestamps: '0'
|
|
});
|
|
const stream = logStreamHandle.stream;
|
|
|
|
if (hasTty) {
|
|
// Plain text — just split on newlines.
|
|
let buf = '';
|
|
stream.on('data', chunk => {
|
|
buf += chunk.toString('utf8');
|
|
const lines = buf.split('\n');
|
|
buf = lines.pop();
|
|
if (lines.length) send('log', { stream: 'stdout', lines });
|
|
});
|
|
stream.on('end', () => {
|
|
if (buf) send('log', { stream: 'stdout', lines: [buf] });
|
|
send('end', { code: 0 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
});
|
|
} else {
|
|
// Multiplexed framing:
|
|
// [stream_type:1][0:3][size:4 BE][payload:size]
|
|
// stream_type: 1=stdout, 2=stderr (0=stdin, never seen here)
|
|
let pending = Buffer.alloc(0);
|
|
let stdoutBuf = '';
|
|
let stderrBuf = '';
|
|
|
|
const flush = (which, line) => {
|
|
const buf = which === 'stdout' ? stdoutBuf : stderrBuf;
|
|
const all = buf + line;
|
|
const lines = all.split('\n');
|
|
const tailPart = lines.pop();
|
|
if (which === 'stdout') stdoutBuf = tailPart; else stderrBuf = tailPart;
|
|
if (lines.length) send('log', { stream: which, lines });
|
|
};
|
|
|
|
stream.on('data', chunk => {
|
|
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
while (pending.length >= 8) {
|
|
const streamType = pending[0];
|
|
const size = pending.readUInt32BE(4);
|
|
if (pending.length < 8 + size) break; // wait for more bytes
|
|
const payload = pending.slice(8, 8 + size).toString('utf8');
|
|
pending = pending.slice(8 + size);
|
|
flush(streamType === 2 ? 'stderr' : 'stdout', payload);
|
|
}
|
|
});
|
|
stream.on('end', () => {
|
|
if (stdoutBuf) send('log', { stream: 'stdout', lines: [stdoutBuf] });
|
|
if (stderrBuf) send('log', { stream: 'stderr', lines: [stderrBuf] });
|
|
send('end', { code: 0 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
});
|
|
}
|
|
|
|
stream.on('error', err => {
|
|
send('error', { message: err.message });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
});
|
|
} catch (err) {
|
|
send('error', { message: err.message });
|
|
send('end', { code: 500 });
|
|
cleanup();
|
|
try { res.end(); } catch { /* already done */ }
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|