// dotnews-paas panel — a very small self-hosted deployment dashboard. // Fastify 5 + EJS + better-sqlite3, listening on 127.0.0.1 behind Caddy. import fs from 'node:fs'; import path from 'node:path'; import { createHash, randomBytes } from 'node:crypto'; import { spawn } from 'node:child_process'; import Fastify from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyFormbody from '@fastify/formbody'; import fastifyView from '@fastify/view'; import ejs from 'ejs'; import { config, panelRoot, NAME_RE } from './lib/config.js'; import * as db from './lib/db.js'; import * as auth from './lib/auth.js'; import * as paas from './lib/paas.js'; import { enqueueDeploy } from './lib/queue.js'; import { verifyWebhook, extractPush } from './lib/hooks.js'; import * as git from './lib/git.js'; import { takeSnapshot, snapshotPath } from './lib/snapshot.js'; git.setRefreshDeps({ getOauthApp: (p) => db.getOauthApp(p), updateGitAccountToken: (...a) => db.updateGitAccountToken(...a) }); const ACTIVE = ['queued', 'cloning', 'building', 'deploying']; // ---------- template helpers ---------- const helpers = { fmtTime(ts) { if (!ts) return '—'; const d = new Date(String(ts).replace(' ', 'T') + 'Z'); if (Number.isNaN(d.getTime())) return String(ts); return d.toLocaleString('en-GB', { hour12: false }); }, toMs(ts) { if (!ts) return 0; const d = new Date(String(ts).replace(' ', 'T') + 'Z'); return Number.isNaN(d.getTime()) ? 0 : d.getTime(); }, duration(start, end) { const a = helpers.toMs(start); if (!a) return '—'; const b = end ? helpers.toMs(end) : Date.now(); const s = Math.max(0, Math.round((b - a) / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; }, shortSha(sha) { return sha ? String(sha).slice(0, 7) : '—'; }, isActive(status) { return ACTIVE.includes(status); }, // Relative time from an epoch-ms value ("just now", "14 min ago", "3 h ago", "2 d ago"). relTimeMs(ms) { if (!ms) return 'never'; const s = Math.max(0, Math.floor((Date.now() - ms) / 1000)); if (s < 60) return 'just now'; const m = Math.floor(s / 60); if (m < 60) return `${m} min ago`; const hrs = Math.floor(m / 60); if (hrs < 24) return `${hrs} h ago`; return `${Math.floor(hrs / 24)} d ago`; }, // Relative time from a sqlite datetime('now') string (UTC, no timezone suffix). relTime(ts) { return helpers.relTimeMs(helpers.toMs(ts)); }, utcMs(ms) { return ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : ''; }, // "git@github.com:dotnews/dotnews-an.git" / "https://github.com/dotnews/dotnews-an.git" // -> "dotnews/dotnews-an" repoShort(url) { let s = String(url || '').trim().replace(/\.git\/?$/, ''); const scp = /^[A-Za-z0-9._-]+@[^:]+:(.+)$/.exec(s); if (scp) s = scp[1]; else s = s.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/]+\//, ''); s = s.replace(/^\/+/, ''); return s || String(url || ''); }, depLabel(s) { return s === 'live' ? '\u25CF Ready' : s; }, fmtBytes(n) { n = Number(n) || 0; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; }, fmtInt(n) { return (Number(n) || 0).toLocaleString('en-US'); }, flag(cc) { const s = String(cc || '').toUpperCase(); if (!/^[A-Z]{2}$/.test(s)) return ''; return String.fromCodePoint(0x1f1e6 + s.charCodeAt(0) - 65, 0x1f1e6 + s.charCodeAt(1) - 65); }, }; const tailStr = (s, n = 500) => String(s || '').trim().slice(-n); function readLogTail(logPath, maxBytes = 512 * 1024) { if (!logPath) return '(no log file)'; try { const st = fs.statSync(logPath); const start = Math.max(0, st.size - maxBytes); const fd = fs.openSync(logPath, 'r'); const buf = Buffer.alloc(st.size - start); fs.readSync(fd, buf, 0, buf.length, start); fs.closeSync(fd); const head = start > 0 ? `[... truncated, showing last ${Math.round(maxBytes / 1024)} KB ...]\n` : ''; return head + buf.toString('utf8'); } catch { return '(log file not found)'; } } function readDeployKey() { try { return fs.readFileSync(config.deployKeyPub, 'utf8').trim(); } catch { return ''; } } // ---------- app ---------- const app = Fastify({ logger: true, trustProxy: true, bodyLimit: 5 * 1024 * 1024 }); // Preserve raw body bytes — webhook signatures are HMACs over the exact payload. function rawJsonParser(req, body, done) { req.rawBody = body; if (!body || body.length === 0) return done(null, {}); try { done(null, JSON.parse(body.toString('utf8'))); } catch { done(null, {}); } } app.addContentTypeParser('application/json', { parseAs: 'buffer' }, rawJsonParser); app.addContentTypeParser('*', { parseAs: 'buffer' }, rawJsonParser); await app.register(fastifyCookie); await app.register(fastifyFormbody); await app.register(fastifyView, { engine: { ejs }, root: path.join(panelRoot, 'views'), layout: 'layout.ejs', defaultContext: { h: helpers, authed: true }, }); // ---------- auth gate ---------- app.addHook('onRequest', async (req, reply) => { const url = req.url.split('?')[0]; if (url === '/login' || url.startsWith('/hooks/') || url === '/cmk-agent' || url === '/cmk-sites') return; const uid = auth.verifySession(req.cookies && req.cookies[auth.COOKIE_NAME]); const user = uid ? db.getUser(uid) : null; if (user) { req.user = user; return; } if (url.startsWith('/api/') || url.endsWith('/log/stream')) { return reply.code(401).send({ error: 'unauthorized' }); } return reply.redirect('/login'); }); function requireAdmin(req, reply) { if (req.user && req.user.role === 'admin') return true; reply.code(403).send('Admins only'); return false; } function setSessionCookie(reply, userId) { reply.setCookie(auth.COOKIE_NAME, auth.createSession(userId), { path: '/', httpOnly: true, sameSite: 'lax', secure: config.baseUrl.startsWith('https://'), maxAge: 7 * 24 * 3600, }); } // ---------- login / logout ---------- app.get('/login', async (req, reply) => { const uid = auth.verifySession(req.cookies && req.cookies[auth.COOKIE_NAME]); if (uid && db.getUser(uid)) return reply.redirect('/'); return reply.view('login.ejs', { title: 'Login', error: null, authed: false }); }); app.post('/login', async (req, reply) => { const ip = req.ip; if (auth.loginBlocked(ip)) { return reply.code(429).view('login.ejs', { title: 'Login', error: 'Too many failed attempts. Try again in 15 minutes.', authed: false, }); } const username = String((req.body && req.body.username) || '').trim(); const password = String((req.body && req.body.password) || ''); const user = username ? db.getUserByUsername(username) : null; if (user && auth.verifyHash(user.password_hash, password)) { auth.loginSucceeded(ip); setSessionCookie(reply, user.id); return reply.redirect('/'); } auth.loginFailed(ip); return reply.code(401).view('login.ejs', { title: 'Login', error: 'Wrong username or password.', authed: false }); }); // ---------- user management (admin) + own account ---------- const USERNAME_RE = /^[a-z][a-z0-9_.-]{1,30}$/; app.get('/users', async (req, reply) => { if (!requireAdmin(req, reply)) return; return reply.view('users.ejs', { user: req.user, title: 'Users', users: db.listUsers(), msg: req.query.msg || null, err: req.query.err || null, }); }); app.post('/users', async (req, reply) => { if (!requireAdmin(req, reply)) return; const b = req.body || {}; const username = String(b.username || '').trim().toLowerCase(); const password = String(b.password || ''); const role = b.role === 'admin' ? 'admin' : 'user'; if (!USERNAME_RE.test(username)) return reply.redirect('/users?err=Invalid+username+(lowercase,+2-31+chars)'); if (password.length < 8) return reply.redirect('/users?err=Password+must+have+at+least+8+characters'); if (db.getUserByUsername(username)) return reply.redirect('/users?err=Username+already+exists'); db.addUser({ username, password_hash: auth.hashPassword(password), role }); return reply.redirect(`/users?msg=${encodeURIComponent(`User ${username} created (${role})`)}`); }); app.post('/users/:id/password', async (req, reply) => { if (!requireAdmin(req, reply)) return; const target = db.getUser(Number(req.params.id || 0)); const password = String((req.body && req.body.password) || ''); if (!target) return reply.redirect('/users?err=Unknown+user'); if (password.length < 8) return reply.redirect('/users?err=Password+must+have+at+least+8+characters'); db.updateUserPassword(target.id, auth.hashPassword(password)); return reply.redirect(`/users?msg=${encodeURIComponent(`Password reset for ${target.username}`)}`); }); app.post('/users/:id/delete', async (req, reply) => { if (!requireAdmin(req, reply)) return; const target = db.getUser(Number(req.params.id || 0)); if (!target) return reply.redirect('/users?err=Unknown+user'); if (target.id === req.user.id) return reply.redirect('/users?err=You+cannot+delete+yourself'); if (target.role === 'admin' && db.countAdmins() <= 1) return reply.redirect('/users?err=Cannot+delete+the+last+admin'); const owned = db.listProjectsFor(target).length; if (owned > 0) return reply.redirect(`/users?err=${encodeURIComponent(`User still owns ${owned} project(s) — remove or reassign them first`)}`); db.deleteUser(target.id); return reply.redirect(`/users?msg=${encodeURIComponent(`User ${target.username} deleted`)}`); }); app.get('/account', async (req, reply) => { return reply.view('account.ejs', { user: req.user, title: 'Account', msg: req.query.msg || null, err: req.query.err || null, }); }); app.post('/account/password', async (req, reply) => { const current = String((req.body && req.body.current) || ''); const next = String((req.body && req.body.next) || ''); if (!auth.verifyHash(req.user.password_hash, current)) { return reply.redirect('/account?err=Current+password+is+wrong'); } if (next.length < 8) return reply.redirect('/account?err=New+password+must+have+at+least+8+characters'); db.updateUserPassword(req.user.id, auth.hashPassword(next)); return reply.redirect('/account?msg=Password+changed'); }); app.post('/logout', async (req, reply) => { reply.clearCookie(auth.COOKIE_NAME, { path: '/' }); return reply.redirect('/login'); }); // ---------- dashboard ---------- app.get('/', async (req, reply) => { const projects = db.listProjectsFor(req.user); const owners = {}; if (req.user.role === 'admin') { for (const u of db.listUsers()) owners[u.id] = u.username; } const latest = {}; for (const d of db.latestPerApp()) latest[d.app] = d; const lastLive = {}; for (const d of db.latestLivePerApp()) lastLive[d.app] = d; const pm2 = { ...(await paas.pm2Status()) }; // laravel apps have no pm2 entry — their "process" is the fpm pool socket for (const p of projects) if (p.runtime === 'laravel') pm2[p.name] = paas.fpmState(p.name); const infos = {}; await Promise.all(projects.map(async (p) => { infos[p.name] = await paas.paasInfoCached(p.name); })); const active = db.activeDeployments()[0] || null; const trafficToday = db.trafficTodayPerApp(); const accessFlags = Object.fromEntries(projects.map((pr) => [pr.name, paas.appAccessFlags(pr.name)])); return reply.view('index.ejs', { user: req.user, title: 'Projects', projects, accessFlags, latest, lastLive, pm2, infos, active, trafficToday, owners, msg: req.query.msg || null, err: req.query.err || null, }); }); app.get('/api/status', async (req) => { const visibleProjects = db.listProjectsFor(req.user); const visible = new Set(visibleProjects.map((p) => p.name)); const deployments = {}; for (const d of db.latestPerApp()) { if (!visible.has(d.app)) continue; deployments[d.app] = { id: d.id, status: d.status, step: d.step, started_at: d.started_at, started_ms: helpers.toMs(d.started_at), }; } const active = db.activeDeployments().filter((d) => visible.has(d.app)).map((d) => ({ id: d.id, app: d.app, status: d.status, step: d.step, started_ms: helpers.toMs(d.started_at), })); const pm2 = { ...(await paas.pm2Status()) }; for (const p of visibleProjects) if (p.runtime === 'laravel') pm2[p.name] = paas.fpmState(p.name); for (const app of visible) { if (!paas.appAccessFlags(app).enabled) pm2[app] = 'deactivated'; } return { deployments, active, pm2 }; }); // ---------- new project ---------- const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i; const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; const REPO_RE = /^(https?:\/\/|git@|ssh:\/\/)\S+$/; const OUTDIR_RE = /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/; // Runtime presets for the new-project form (also used by new.ejs to switch // defaults client-side — keep both in sync). const RUNTIME_DEFAULTS = { node: { build_cmd: 'npm ci && npm run build', output_dir: '.output', start_cmd: 'node server/index.mjs', health: '/' }, laravel: { build_cmd: 'composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader && if [ -f package.json ]; then npm ci && npm run build; fi', output_dir: '.', start_cmd: '', health: '/up', }, }; const HEALTH_RE = /^\/[A-Za-z0-9_./-]*$/; function validateProjectInput(b) { const caps = paas.capabilities(); const runtime = String(b.runtime || 'node').trim(); if (!RUNTIME_DEFAULTS[runtime]) return { error: 'Invalid runtime.' }; if (runtime === 'laravel' && !caps.laravel) { return { error: 'The Laravel runtime is not enabled on this server (bootstrap with WITH_LARAVEL=1).' }; } const defaults = RUNTIME_DEFAULTS[runtime]; const name = String(b.name || '').trim(); const repo_url = String(b.repo_url || '').trim(); const branch = String(b.branch || '').trim() || 'main'; const build_cmd = String(b.build_cmd || '').trim() || defaults.build_cmd; const output_dir = String(b.output_dir || '').trim() || defaults.output_dir; const start_cmd = runtime === 'laravel' ? '' : (String(b.start_cmd || '').trim() || defaults.start_cmd); const health_path = String(b.health_path || '').trim(); const php_version = String(b.php_version || '').trim(); const domains = String(b.domains || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); if (!NAME_RE.test(name)) return { error: 'Invalid name — must match ^[a-z][a-z0-9-]{1,30}$.' }; if (health_path && !HEALTH_RE.test(health_path)) return { error: 'Invalid health path — must start with / (letters, digits, _ . / -).' }; if (runtime === 'laravel') { if (b.cache) return { error: 'Varnish cache is not available for Laravel apps (the edge strips cookies — sessions/CSRF would break).' }; if (php_version && !caps.phpVersions.includes(php_version)) return { error: `PHP ${php_version} is not installed on this server.` }; } // repo is optional: repo-less projects deploy via ZIP upload and can link // a repository later (project page → Repository) if (repo_url && !REPO_RE.test(repo_url)) return { error: 'Invalid repository URL (https://…, ssh://… or git@…).' }; if (!BRANCH_RE.test(branch)) return { error: 'Invalid branch name.' }; if (!OUTDIR_RE.test(output_dir) || output_dir.split('/').includes('..')) { return { error: 'Invalid output directory — must be a relative path inside the checkout.' }; } // domains optional — an always-available system domain is generated by paas for (const d of domains) { if (!DOMAIN_RE.test(d)) return { error: `Invalid domain: ${d}` }; } return { values: { name, repo_url, branch, build_cmd, output_dir, start_cmd, domains, cache: Boolean(b.cache), noindex: Boolean(b.noindex), env: String(b.env || ''), group_name: String(b.group_name || '').trim().slice(0, 40) || null, node_version: /^(system|\d{1,3})$/.test(String(b.node_version || '')) ? String(b.node_version) : 'system', runtime, php_version: runtime === 'laravel' ? (php_version || caps.phpDefault) : '', health_path, create_db: Boolean(b.create_db) && caps.db, }, }; } // Render context shared by every new.ejs response function newProjectCtx(req, extra) { return { user: req.user, title: 'New project', error: null, values: { cache: 1 }, groups: db.listGroups(req.user.id), gitAccounts: db.listGitAccounts(req.user.id), nodeVersions: paas.nodeVersions(), capabilities: paas.capabilities(), runtimeDefaults: RUNTIME_DEFAULTS, ...extra, }; } app.get('/projects/new', async (req, reply) => { return reply.view('new.ejs', newProjectCtx(req)); }); app.post('/projects/:name/group', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const group = String((req.body && req.body.group_name) || '').trim().slice(0, 40); db.setProjectGroup(p.name, group || null); return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(group ? `Group: ${group}` : 'Group removed')}`); }); // ---------- group management ---------- const GROUP_RE = /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,39}$/; app.get('/groups', async (req, reply) => { return reply.view('groups.ejs', { user: req.user, title: 'Groups', counts: db.groupCounts(req.user.id), msg: req.query.msg || null, err: req.query.err || null, }); }); app.post('/groups', async (req, reply) => { const name = String((req.body && req.body.name) || '').trim(); if (!GROUP_RE.test(name)) return reply.redirect('/groups?err=Invalid+group+name'); db.addGroup(req.user.id, name); return reply.redirect(`/groups?msg=${encodeURIComponent(`Group added: ${name}`)}`); }); app.post('/groups/rename', async (req, reply) => { const oldName = String((req.body && req.body.old_name) || '').trim(); const newName = String((req.body && req.body.new_name) || '').trim(); if (!GROUP_RE.test(newName) || !oldName) return reply.redirect('/groups?err=Invalid+name'); db.renameGroup(req.user.id, oldName, newName); return reply.redirect(`/groups?msg=${encodeURIComponent(`Renamed to ${newName} (projects moved)`)}`); }); app.post('/groups/delete', async (req, reply) => { const name = String((req.body && req.body.name) || '').trim(); const counts = db.groupCounts(req.user.id); if (counts[name] > 0) return reply.redirect('/groups?err=Group+is+not+empty+—+move+its+projects+first'); db.deleteGroup(req.user.id, name); return reply.redirect(`/groups?msg=${encodeURIComponent(`Group deleted: ${name}`)}`); }); app.post('/projects/new', async (req, reply) => { const b = req.body || {}; // want_json=1: the new-project form submits via fetch when a ZIP file is // selected, so it can chain the upload right after creation. const wantJson = String(b.want_json || '') === '1'; const { error, values: v } = validateProjectInput(b); if (error) { if (wantJson) return reply.code(400).send({ ok: false, error }); return reply.code(400).view('new.ejs', newProjectCtx(req, { error, values: b })); } if (db.getProject(v.name)) { const msg = `Project "${v.name}" already exists.`; if (wantJson) return reply.code(400).send({ ok: false, error: msg }); return reply.code(400).view('new.ejs', newProjectCtx(req, { error: msg, values: b })); } const webhookSecret = randomBytes(16).toString('hex'); db.insertProject({ name: v.name, repo_url: v.repo_url, branch: v.branch, build_cmd: v.build_cmd, output_dir: v.output_dir, webhook_secret: webhookSecret, runtime: v.runtime, }); db.setProjectOwner(v.name, req.user.id); if (v.group_name) db.setProjectGroup(v.name, v.group_name); const res = await paas.paasCreate(v.name, { domains: v.domains, cache: v.cache, noindex: v.noindex, startCmd: v.start_cmd, nodeVersion: v.node_version, runtime: v.runtime, phpVersion: v.php_version, health: v.health_path, }); if (res.code !== 0) { db.deleteProject(v.name); const detail = tailStr(res.output) || (res.timedOut ? 'paas create timed out after 60s' : `paas create exited with code ${res.code}`); if (wantJson) return reply.code(500).send({ ok: false, error: `paas create failed: ${detail}` }); return reply.code(500).view('new.ejs', newProjectCtx(req, { error: `paas create failed: ${detail}`, values: b })); } try { if (v.env.trim()) { // `paas create --runtime laravel` seeds APP_KEY/APP_ENV/APP_DEBUG/APP_URL/ // LOG_CHANNEL into shared/.env — merge, never overwrite those with an // empty form (a lost APP_KEY would invalidate every session/encrypted value). const seeded = paas.parseEnv(paas.readAppEnv(v.name)); const user = paas.parseEnv(v.env); const keep = Object.entries(seeded).filter(([k]) => !(k in user)).map(([k, val]) => `${k}=${val}`); paas.writeAppEnv(v.name, (keep.length ? keep.join('\n') + '\n' : '') + v.env); } } catch (err) { if (wantJson) return reply.send({ ok: true, name: v.name, warn: `writing .env failed: ${err.message}` }); return reply.redirect(`/projects/${v.name}?err=` + encodeURIComponent(`Project created, but writing .env failed: ${err.message}`)); } let dbNote = ''; if (v.create_db) { const d = await paas.paasDb(v.name, 'create'); dbNote = d.code === 0 ? ' · database created (credentials in the env)' : ` · database creation FAILED: ${tailStr(d.output) || 'exit ' + d.code}`; } // Git integration: provision deploy key + webhook on the provider, then // start the first build immediately (Vercel-style import flow). const accId = Number(b.git_account_id || 0); const repoFull = String(b.repo_full || '').trim(); if (accId && repoFull && /^[\w.-]+(\/[\w.-]+)+$/.test(repoFull)) { const acc0 = db.getGitAccount(accId); let acc = acc0 && acc0.owner_user_id === req.user.id ? acc0 : null; if (acc) acc = await git.freshAccount(acc); if (acc) { db.setProjectGit(v.name, acc.id, repoFull); const parts = []; const pubKey = readDeployKey(); if (pubKey) { const kr = await git.ensureDeployKey(acc, repoFull, `paas ${config.baseUrl}`, pubKey); parts.push(`deploy key ${kr.ok ? (kr.existed ? 'already set' : 'added') : 'FAILED: ' + kr.error}`); } else { parts.push('deploy key MISSING on server'); } const hr = await git.ensureWebhook(acc, repoFull, `${config.baseUrl}/hooks/${v.name}/${webhookSecret}`, webhookSecret); parts.push(`webhook ${hr.ok ? (hr.existed ? 'already set' : 'added') : 'FAILED: ' + hr.error}`); const project = db.getProject(v.name); const depId = enqueueDeploy(project, { source: 'panel' }); if (wantJson) return reply.send({ ok: true, name: v.name, deployment: depId }); return reply.redirect(`/deployments/${depId}?msg=` + encodeURIComponent(`Project created — ${parts.join(' · ')}${dbNote} — first build started.`)); } } if (wantJson) return reply.send({ ok: true, name: v.name }); return reply.redirect(`/projects/${v.name}?msg=` + encodeURIComponent(`Project created${dbNote}. Add the webhook to your repository to enable push deploys.`)); }); // ---------- git accounts + repo browsing ---------- app.get('/settings/git', async (req, reply) => { return reply.view('git-accounts.ejs', { user: req.user, title: 'Git accounts', accounts: db.listGitAccounts(req.user.id), oauthGithub: db.getOauthApp('github') || null, oauthGitlab: db.getOauthApp('gitlab') || null, msg: req.query.msg || null, err: req.query.err || null, }); }); app.post('/settings/git', async (req, reply) => { const b = req.body || {}; const provider = String(b.provider || ''); const label = String(b.label || '').trim().slice(0, 60); const token = String(b.token || '').trim(); const apiBase = String(b.api_base || '').trim(); if (!['github', 'gitlab'].includes(provider) || !label || !token) { return reply.redirect('/settings/git?err=Provider,+label+and+token+are+required'); } if (apiBase && !/^https:\/\/[\w.-]+(:\d+)?(\/[\w./-]*)?$/.test(apiBase)) { return reply.redirect('/settings/git?err=Invalid+API+base+URL'); } const probe = await git.testAccount({ provider, token, api_base: apiBase || null }); if (!probe.ok) { return reply.redirect(`/settings/git?err=` + encodeURIComponent(`Token check failed: ${probe.error}`)); } db.addGitAccount({ owner_user_id: req.user.id, provider, label, token, api_base: apiBase || null }); return reply.redirect(`/settings/git?msg=` + encodeURIComponent(`Connected as ${probe.user} (${provider})`)); }); app.post('/settings/git/:id/delete', async (req, reply) => { db.deleteGitAccount(Number(req.params.id || 0), req.user.id); return reply.redirect('/settings/git?msg=Account+removed'); }); // ---------- OAuth provider apps (admin) + connect flows ---------- app.post('/settings/git/apps', async (req, reply) => { if (!requireAdmin(req, reply)) return; const b = req.body || {}; const provider = String(b.provider || ''); const clientId = String(b.client_id || '').trim(); const apiBase = String(b.api_base || '').trim(); if (!['github', 'gitlab'].includes(provider)) return reply.redirect('/settings/git?err=Invalid+provider'); if (b.action === 'delete') { db.deleteOauthApp(provider); return reply.redirect(`/settings/git?msg=${encodeURIComponent(`${provider} OAuth app removed`)}`); } if (!/^[\w.-]{8,120}$/.test(clientId)) return reply.redirect('/settings/git?err=Invalid+client+ID'); if (apiBase && !/^https:\/\/[\w.-]+(:\d+)?$/.test(apiBase)) return reply.redirect('/settings/git?err=Invalid+base+URL'); const clientSecret = String(b.client_secret || '').trim(); db.setOauthApp({ provider, client_id: clientId, client_secret: clientSecret || null, api_base: apiBase || null }); return reply.redirect(`/settings/git?msg=${encodeURIComponent(`${provider} OAuth app saved — Connect button active`)}`); }); // pending OAuth states (in-memory, 15 min TTL) const oauthPending = new Map(); function prunePending() { const now = Date.now(); for (const [k, v] of oauthPending) if (now - v.created > 15 * 60 * 1000) oauthPending.delete(k); } // GitHub connect: web flow (redirect) when a client secret is configured, // otherwise the Device Flow (code entry) as fallback. // ?reconnect=: re-run the OAuth flow against that existing // row (owner-checked) instead of Connect's normal insert-a-new-account path // — used by the "Reconnect" button in git-accounts.ejs so a stale/expired // account's token gets refreshed in place, with every project's // git_account_id link left pointing at the same id. function validReconnectId(req) { const id = Number(req.query.reconnect || 0); if (!id) return null; const acc = db.getGitAccount(id); return acc && acc.owner_user_id === req.user.id && acc.provider === 'github' ? id : null; } app.get('/auth/github/start', async (req, reply) => { prunePending(); const appCfg = db.getOauthApp('github'); if (!appCfg) return reply.redirect('/settings/git?err=GitHub+OAuth+app+not+configured'); const reconnectId = validReconnectId(req); if (req.query.reconnect && !reconnectId) return reply.redirect('/settings/git?err=Unknown+account+to+reconnect'); if (appCfg.client_secret) { const state = randomBytes(16).toString('hex'); oauthPending.set(state, { kind: 'github-web', userId: req.user.id, created: Date.now(), reconnectId }); const params = new URLSearchParams({ client_id: appCfg.client_id, redirect_uri: `${config.baseUrl}/auth/github/callback`, scope: 'repo admin:repo_hook', state, }); return reply.redirect(`https://github.com/login/oauth/authorize?${params.toString()}`); } return githubDeviceStart(req, reply, appCfg, reconnectId); }); app.get('/auth/github/callback', async (req, reply) => { const state = String(req.query.state || ''); const code = String(req.query.code || ''); const pending = oauthPending.get(state); if (!pending || pending.kind !== 'github-web' || pending.userId !== req.user.id || !code) { return reply.redirect('/settings/git?err=OAuth+state+mismatch+or+expired+—+try+again'); } oauthPending.delete(state); const appCfg = db.getOauthApp('github'); if (!appCfg || !appCfg.client_secret) return reply.redirect('/settings/git?err=GitHub+OAuth+app+not+configured'); let j = null; try { const res = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ client_id: appCfg.client_id, client_secret: appCfg.client_secret, code, redirect_uri: `${config.baseUrl}/auth/github/callback`, }), signal: AbortSignal.timeout(15000), }); j = await res.json().catch(() => null); } catch (err) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitHub unreachable: ${err.message}`)}`); } if (!j || !j.access_token) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitHub token exchange failed: ${(j && (j.error_description || j.error)) || 'no token'}`)}`); } const probe = await git.testAccount({ provider: 'github', token: j.access_token, api_base: null }); const msg = finishGithubConnect({ ownerId: req.user.id, reconnectId: pending.reconnectId, label: `github ${probe.ok ? probe.user : ''}`.trim(), token: j.access_token, refresh_token: j.refresh_token || null, token_expires_at: j.expires_in ? Date.now() + Number(j.expires_in) * 1000 : null, probeUser: probe.ok ? probe.user : null, }); return reply.redirect(`/settings/git?msg=${encodeURIComponent(msg)}`); }); // Shared by both the web-flow callback and the device-flow poll: writes the // token either into a fresh row (Connect) or an existing one (Reconnect — // see validReconnectId above), and returns the flash message for either case. function finishGithubConnect({ ownerId, reconnectId, label, token, refresh_token, token_expires_at, probeUser }) { if (reconnectId) { const ok = db.reconnectOauthGitAccount(reconnectId, ownerId, { label, token, refresh_token, token_expires_at }); if (ok) return `GitHub reconnected${probeUser ? ` as ${probeUser}` : ''}`; // Account was deleted/reassigned between clicking Reconnect and finishing // the OAuth round-trip — don't drop the token on the floor, save it as a // new account instead. } db.addOauthGitAccount({ owner_user_id: ownerId, provider: 'github', label, token, api_base: null, // GitHub OAuth Apps with "Enable token expiration" on issue an 8h access // token alongside these — without capturing them here, freshAccount() has // nothing to refresh with and the connection silently dies at ~8h. refresh_token, token_expires_at, }); return `GitHub connected${probeUser ? ` as ${probeUser}` : ''}${reconnectId ? ' (original account was gone — added as new)' : ''}`; } async function githubDeviceStart(req, reply, appCfg, reconnectId) { let res; try { res = await fetch('https://github.com/login/device/code', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ client_id: appCfg.client_id, scope: 'repo admin:repo_hook' }), signal: AbortSignal.timeout(15000), }); } catch (err) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitHub unreachable: ${err.message}`)}`); } const j = await res.json().catch(() => null); if (!res.ok || !j || !j.device_code) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitHub device flow failed: ${(j && (j.error_description || j.error)) || res.status}. Is the Device Flow enabled on the OAuth app?`)}`); } const pollId = randomBytes(16).toString('hex'); oauthPending.set(pollId, { kind: 'github-device', device_code: j.device_code, interval: Number(j.interval) || 5, userId: req.user.id, created: Date.now(), reconnectId: reconnectId || null }); return reply.view('github-device.ejs', { user: req.user, title: reconnectId ? 'Reconnect GitHub' : 'Connect GitHub', userCode: j.user_code, verificationUri: j.verification_uri || 'https://github.com/login/device', pollId, interval: Number(j.interval) || 5, }); } app.post('/auth/github/start', async (req, reply) => { prunePending(); const appCfg = db.getOauthApp('github'); if (!appCfg) return reply.redirect('/settings/git?err=GitHub+OAuth+app+not+configured'); return githubDeviceStart(req, reply, appCfg, validReconnectId(req)); }); app.get('/auth/github/poll', async (req, reply) => { const pending = oauthPending.get(String(req.query.id || '')); if (!pending || pending.kind !== 'github-device' || pending.userId !== req.user.id) { return reply.send({ error: 'expired' }); } const appCfg = db.getOauthApp('github'); if (!appCfg) return reply.send({ error: 'app removed' }); let j = null; try { const res = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ client_id: appCfg.client_id, device_code: pending.device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code', }), signal: AbortSignal.timeout(15000), }); j = await res.json().catch(() => null); } catch { return reply.send({ pending: true }); } if (j && j.access_token) { oauthPending.delete(String(req.query.id)); const probe = await git.testAccount({ provider: 'github', token: j.access_token, api_base: null }); finishGithubConnect({ ownerId: req.user.id, reconnectId: pending.reconnectId, label: `github ${probe.ok ? probe.user : ''}`.trim(), token: j.access_token, refresh_token: j.refresh_token || null, token_expires_at: j.expires_in ? Date.now() + Number(j.expires_in) * 1000 : null, probeUser: probe.ok ? probe.user : null, }); return reply.send({ done: true }); } if (j && (j.error === 'authorization_pending' || j.error === 'slow_down')) return reply.send({ pending: true }); oauthPending.delete(String(req.query.id)); return reply.send({ error: (j && (j.error_description || j.error)) || 'failed' }); }); // GitLab PKCE web flow function b64url(buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } app.get('/auth/gitlab/start', async (req, reply) => { prunePending(); const appCfg = db.getOauthApp('gitlab'); if (!appCfg) return reply.redirect('/settings/git?err=GitLab+OAuth+app+not+configured'); const state = randomBytes(16).toString('hex'); const verifier = b64url(randomBytes(48)); const challenge = b64url(createHash('sha256').update(verifier).digest()); oauthPending.set(state, { kind: 'gitlab-pkce', verifier, userId: req.user.id, created: Date.now() }); const base = (appCfg.api_base || 'https://gitlab.com').replace(/\/+$/, ''); const params = new URLSearchParams({ client_id: appCfg.client_id, redirect_uri: `${config.baseUrl}/auth/gitlab/callback`, response_type: 'code', state, scope: 'api', code_challenge: challenge, code_challenge_method: 'S256', }); return reply.redirect(`${base}/oauth/authorize?${params.toString()}`); }); app.get('/auth/gitlab/callback', async (req, reply) => { const state = String(req.query.state || ''); const code = String(req.query.code || ''); const pending = oauthPending.get(state); if (!pending || pending.kind !== 'gitlab-pkce' || pending.userId !== req.user.id || !code) { return reply.redirect('/settings/git?err=OAuth+state+mismatch+or+expired+—+try+again'); } oauthPending.delete(state); const appCfg = db.getOauthApp('gitlab'); if (!appCfg) return reply.redirect('/settings/git?err=GitLab+OAuth+app+not+configured'); const base = (appCfg.api_base || 'https://gitlab.com').replace(/\/+$/, ''); let j = null; try { const res = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: appCfg.client_id, code, grant_type: 'authorization_code', redirect_uri: `${config.baseUrl}/auth/gitlab/callback`, code_verifier: pending.verifier, }), signal: AbortSignal.timeout(15000), }); j = await res.json().catch(() => null); if (!res.ok || !j || !j.access_token) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitLab token exchange failed: ${(j && (j.error_description || j.error)) || res.status}`)}`); } } catch (err) { return reply.redirect(`/settings/git?err=${encodeURIComponent(`GitLab unreachable: ${err.message}`)}`); } const apiBase = appCfg.api_base ? `${base}/api/v4` : null; const probe = await git.testAccount({ provider: 'gitlab', token: j.access_token, api_base: apiBase, auth_type: 'oauth' }); db.addOauthGitAccount({ owner_user_id: req.user.id, provider: 'gitlab', label: `gitlab ${probe.ok ? probe.user : ''}`.trim(), token: j.access_token, api_base: apiBase, refresh_token: j.refresh_token || null, token_expires_at: Date.now() + (Number(j.expires_in) || 7200) * 1000, }); return reply.redirect(`/settings/git?msg=${encodeURIComponent(`GitLab connected${probe.ok ? ` as ${probe.user}` : ''}`)}`); }); app.get('/api/git/:id/repos', async (req, reply) => { let acc = db.getGitAccount(Number(req.params.id || 0)); if (!acc || acc.owner_user_id !== req.user.id) return reply.code(404).send({ error: 'unknown account' }); acc = await git.freshAccount(acc); const { repos, error } = await git.listRepos(acc); return reply.send({ repos, error }); }); app.get('/api/git/:id/branches', async (req, reply) => { let acc = db.getGitAccount(Number(req.params.id || 0)); const repo = String(req.query.repo || ''); if (!acc || acc.owner_user_id !== req.user.id || !/^[\w.-]+(\/[\w.-]+)+$/.test(repo)) return reply.code(400).send({ error: 'bad request' }); acc = await git.freshAccount(acc); return reply.send({ branches: await git.listBranches(acc, repo) }); }); // ---------- project pages ---------- function findProject(req, reply) { const name = String(req.params.name || ''); if (!NAME_RE.test(name)) { reply.code(404).send('Not found'); return null; } const p = db.getProject(name); if (!p) { reply.code(404).send('Project not found'); return null; } if (req.user.role !== 'admin' && p.owner_user_id !== req.user.id) { reply.code(404).send('Project not found'); return null; } return p; } const DEPLOY_PAGE_SIZE = 20; function deploymentRow(d) { return { id: d.id, status: d.status, label: helpers.depLabel(d.status), step: d.status === 'failed' && d.step ? d.step : null, source: d.source, sha: helpers.shortSha(d.commit_sha), msg: d.commit_msg || null, started: helpers.fmtTime(d.started_at), duration: helpers.duration(d.started_at, d.finished_at), }; } // Older deployments for the project page's "Load more" (cursor = smallest id already shown). app.get('/api/projects/:name/deployments', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const before = Number(req.query.before); if (!Number.isInteger(before) || before < 1) { return reply.code(400).send({ error: 'invalid before cursor' }); } const rows = db.historyByApp(p.name, DEPLOY_PAGE_SIZE + 1, before); const hasMore = rows.length > DEPLOY_PAGE_SIZE; if (hasMore) rows.pop(); return { rows: rows.map(deploymentRow), hasMore }; }); app.get('/projects/:name', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const [info, pm2] = await Promise.all([paas.paasInfo(p.name), paas.pm2Status()]); const primaryDomain = info && info.DOMAINS ? String(info.DOMAINS).split(',')[0].trim() : (info && info.SYSTEM_DOMAIN ? String(info.SYSTEM_DOMAIN).trim() : ''); const lastLive = db.latestLiveByApp(p.name) || null; let snapshotMtimeMs = 0; try { snapshotMtimeMs = Math.floor(fs.statSync(snapshotPath(p.name)).mtimeMs); } catch { /* no snapshot yet */ } // Refresh the snapshot when it is missing or predates the latest live deploy // (fire and forget — the page renders immediately with the old/absent image). if (primaryDomain && (!snapshotMtimeMs || (lastLive && snapshotMtimeMs < helpers.toMs(lastLive.finished_at)))) { takeSnapshot(p.name, primaryDomain); } // 30-day traffic series (gap-filled, bar heights precomputed on max bytes) const trafficRows = db.trafficLast30(p.name); const byDay = Object.fromEntries(trafficRows.map((r) => [r.day, r])); const traffic = []; for (let i = 29; i >= 0; i--) { const day = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); traffic.push(byDay[day] || { day, requests: 0, bytes: 0, hit: 0, miss: 0, s2xx: 0, s4xx: 0, s5xx: 0, bytes_cache: 0, bytes_origin: 0 }); } const maxBytes = Math.max(1, ...traffic.map((t) => t.bytes)); for (const t of traffic) t.pct = Math.round((100 * t.bytes) / maxBytes); const sum = (rows, f) => rows.reduce((n, t) => n + (t[f] || 0), 0); const last7 = traffic.slice(-7); const hit30 = sum(traffic, 'hit'); const cacheable30 = hit30 + sum(traffic, 'miss'); const trafficStats = { today: traffic[29], yesterday: traffic[28], req7: sum(last7, 'requests'), bytes7: sum(last7, 'bytes'), req30: sum(traffic, 'requests'), bytes30: sum(traffic, 'bytes'), hitRate30: cacheable30 ? Math.round((100 * hit30) / cacheable30) : null, cacheBytes30: sum(traffic, 'bytes_cache'), originBytes30: sum(traffic, 'bytes_origin'), }; trafficStats.cacheShare30 = (trafficStats.cacheBytes30 + trafficStats.originBytes30) ? Math.round((100 * trafficStats.cacheBytes30) / (trafficStats.cacheBytes30 + trafficStats.originBytes30)) : null; // Monthly totals: permanently archived months + the running month (live). // Chart shows the most recent 24; the details table lists all. const historyPage = db.historyByApp(p.name, DEPLOY_PAGE_SIZE + 1); const historyHasMore = historyPage.length > DEPLOY_PAGE_SIZE; if (historyHasMore) historyPage.pop(); const monthsAll = db.trafficMonthly(p.name); const months = monthsAll.slice(-24); const maxMonthBytes = Math.max(1, ...months.map((m) => m.bytes)); for (const m of months) m.pct = Math.round((100 * m.bytes) / maxMonthBytes); const thisMonth = monthsAll.length ? monthsAll[monthsAll.length - 1] : null; const prevMonth = monthsAll.length > 1 ? monthsAll[monthsAll.length - 2] : null; return reply.view('project.ejs', { user: req.user, title: p.name, p, info: info || {}, primaryDomain, lastLive, snapshotMtimeMs, traffic, trafficStats, months, monthsAll, thisMonth, prevMonth, pm2State: p.runtime === 'laravel' ? paas.fpmState(p.name) : (pm2[p.name] || 'unknown'), queueState: p.runtime === 'laravel' ? (pm2[`${p.name}-queue`] || 'absent') : null, capabilities: paas.capabilities(), dbInfo: paas.dbInfoFromEnv(p.name), history: historyPage, historyHasMore, geoRules: paas.readGeoRules(p.name), domainRules: paas.readDomainRules(p.name), groups: db.listGroups(req.user.id), gitAccount: p.git_account_id ? db.getGitAccount(p.git_account_id) : null, gitAccounts: db.listGitAccounts(req.user.id), nodeVersions: paas.nodeVersions(), envContent: paas.readAppEnv(p.name), envPath: paas.appEnvPath(p.name), otherProjects: (req.user.role === 'admin' ? db.listProjects() : db.listProjectsFor(req.user)) .filter((x) => x.multi_label).map((x) => x.name).filter((n) => n !== p.name), deployKey: readDeployKey(), webhookUrl: `${config.baseUrl}/hooks/${p.name}/${p.webhook_secret}`, msg: req.query.msg || null, err: req.query.err || null, }); }); app.post('/projects/:name/deploy', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const id = enqueueDeploy(p, { source: 'panel' }); return reply.redirect(`/deployments/${id}`); }); // ZIP-upload deploy: source archive instead of a git checkout — the fallback // for when the git host is down, and the only deploy path for repo-less // projects. Raw request body (the catch-all parser stores it in req.rawBody); // the archive is deleted right after extraction (see lib/queue.js) and stale // leftovers are swept here so uploads never bloat the server. app.post('/projects/:name/upload-zip', { bodyLimit: 512 * 1024 * 1024 }, async (req, reply) => { const p = findProject(req, reply); if (!p) return; const buf = req.rawBody; if (!Buffer.isBuffer(buf) || buf.length < 100) { return reply.code(400).send({ ok: false, error: 'empty upload' }); } // ZIP magic: "PK\x03\x04" (also accepts empty-archive "PK\x05\x06" — reject that) if (!(buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04)) { return reply.code(400).send({ ok: false, error: 'not a ZIP file' }); } const upDir = path.join(config.buildDir, '.uploads'); fs.mkdirSync(upDir, { recursive: true }); // sweep stale uploads (failed/abandoned jobs older than a day) try { const dayAgo = Date.now() - 24 * 3600 * 1000; for (const f of fs.readdirSync(upDir)) { const fp = path.join(upDir, f); if (fs.statSync(fp).mtimeMs < dayAgo) fs.rmSync(fp, { force: true }); } } catch { /* best-effort */ } const zipPath = path.join(upDir, `${p.name}-${Date.now()}.zip`); fs.writeFileSync(zipPath, buf); const id = enqueueDeploy(p, { source: 'upload', uploadZip: zipPath }); return { ok: true, deployment: id }; }); const DOMAIN_ACTION_FLAGS = { add: '--add', remove: '--remove', 'set-main': '--set-main', 'add-redirect': '--add-redirect', 'remove-redirect': '--remove-redirect', }; app.post('/projects/:name/domains', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const action = String((req.body && req.body.action) || ''); const domain = String((req.body && req.body.domain) || '').trim().toLowerCase(); const flag = DOMAIN_ACTION_FLAGS[action]; if (!flag || !DOMAIN_RE.test(domain)) { return reply.redirect(`/projects/${p.name}?err=Invalid+domain+or+action`); } const r = await paas.paasDomains(p.name, [flag, domain]); paas.clearInfoCache && paas.clearInfoCache(p.name); if (r.code !== 0) { return reply.redirect(`/projects/${p.name}?err=${encodeURIComponent(tailStr(r.stderr || r.stdout || 'domain change failed'))}`); } /* Keep the label app's purge config in step with its domains: the /api/_hooks/cms receiver bans exactly the NUXT_BAN_HOSTS list, so a domain change would silently leave the new host unpurged. Only apps that already carry the key are touched (non-label apps stay as-is); the Strapi-side webhook URL cannot be updated from here — remind. */ let note = ''; try { const env = String(paas.readAppEnv(p.name)); if (p.multi_label && /^NUXT_BAN_HOSTS=/m.test(env)) { const info2 = await paas.paasInfo(p.name); const hosts = [ ...String(info2.DOMAINS || '').split(','), String(info2.SYSTEM_DOMAIN || ''), ].map((s) => s.trim()).filter(Boolean); if (hosts.length) { paas.writeAppEnv(p.name, env.replace(/^NUXT_BAN_HOSTS=.*$/m, `NUXT_BAN_HOSTS=${hosts.join(',')}`)); note = ` — NUXT_BAN_HOSTS synced to ${hosts.join(',')} (restart app to apply; update the Strapi purge-webhook URL if the primary domain changed)`; } } } catch { /* env sync is best-effort */ } return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(`Domain ${action}: ${domain}${note}`)}`); }); const BUILD_PRESETS = { standard: 'npm ci && npm run build', legacy: 'npm ci --legacy-peer-deps && npm run build', clean: 'rm -rf node_modules package-lock.json && npm install --legacy-peer-deps && npm run build', }; /* Known build-failure signatures -> suggested preset + explanation. */ function detectBuildFix(logText) { const t = String(logText || ''); if (/Cannot find module @rollup\/rollup-|npm has a bug related to optional dependencies/.test(t)) { return { preset: 'clean', title: 'npm lockfile bug detected', detail: 'The package-lock.json was generated on another platform and npm skipped a required native module (npm issue #4828). Fix: regenerate the lockfile in the build workspace.' }; } if (/can only install packages when your package\.json and package-lock\.json|lock file's .* does not satisfy/.test(t)) { return { preset: 'clean', title: 'package-lock.json out of sync with package.json', detail: 'package.json was changed without running npm install, so the committed lockfile pins different versions and the strict npm ci refuses it. Quick fix: build with a regenerated lockfile (Clean install preset). Proper fix: run npm install locally and commit the updated package-lock.json.' }; } if (/ERESOLVE|Conflicting peer dependency/.test(t)) { return { preset: 'legacy', title: 'Peer-dependency conflict detected', detail: 'A dependency declares an incompatible peer version. Fix: install with --legacy-peer-deps.' }; } if (/heap out of memory|Ineffective mark-compacts/.test(t)) { return { preset: null, env: 'NODE_OPTIONS=--max-old-space-size=6144', title: 'Build ran out of memory', detail: 'The build exceeded the JavaScript heap (default 4 GB here). Fix: raise the limit via NODE_OPTIONS in the project environment.' }; } return null; } app.post('/deployments/:id/fix', async (req, reply) => { const id = Number(req.params.id); const dep = Number.isInteger(id) && id > 0 ? db.getDeployment(id) : null; const p = dep ? db.getProject(dep.app) : null; if (!dep || !p) return reply.code(404).send('Not found'); if (req.user.role !== 'admin' && p.owner_user_id !== req.user.id) return reply.code(404).send('Not found'); const fix = detectBuildFix(readLogTail(dep.log_path)); if (!fix) return reply.redirect(`/deployments/${id}?err=No+known+fix+for+this+failure`); if (fix.preset) { // preserve any custom suffix conventions: keep NITRO_PRESET if it was there const keepNitro = /NITRO_PRESET=node-server/.test(p.build_cmd); let cmd = BUILD_PRESETS[fix.preset]; if (keepNitro) cmd = cmd.replace('npm run build', 'NITRO_PRESET=node-server npm run build'); db.setProjectBuildCmd(p.name, cmd); } else if (fix.env) { const cur = paas.readAppEnv(p.name); if (!/NODE_OPTIONS=/.test(cur)) paas.writeAppEnv(p.name, `${cur.trim()}\n${fix.env}\n`); } const depId = enqueueDeploy(db.getProject(p.name), { source: 'panel' }); return reply.redirect(`/deployments/${depId}?msg=${encodeURIComponent(`Fix applied (${fix.title}) — retrying build`)}`); }); app.post('/projects/:name/build-cmd', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const cmd = String((req.body && req.body.build_cmd) || '').trim().slice(0, 500); if (!cmd) return reply.redirect(`/projects/${p.name}?err=Build+command+cannot+be+empty`); db.setProjectBuildCmd(p.name, cmd); return reply.redirect(`/projects/${p.name}?msg=Build+command+updated`); }); app.get('/projects/:name/branches', async (req, reply) => { const p = findProject(req, reply); if (!p) return; if (!p.repo_url) return reply.send([]); // Projects imported through a connected GitHub/GitLab account are cloned // with a token URL (see queue.js) — a bare ls-remote on repo_url has no // credentials for those private repos and returns nothing, which left the // Branch dropdown with only the current branch. Prefer the provider API, // then the same token clone URL the deploy uses, then the plain URL // (deploy-key / public repos). let remote = p.repo_url; if (p.git_account_id && p.repo_full) { let acc = db.getGitAccount(p.git_account_id); if (acc) { acc = await git.freshAccount(acc); const viaApi = await git.listBranches(acc, p.repo_full); if (viaApi.length) return reply.send([...viaApi].sort()); remote = git.tokenCloneUrl(acc, p.repo_full) || remote; } } const r = await paas.runCmd('git', ['ls-remote', '--heads', '--', remote], { timeout: 20000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }); if (r.code !== 0) return reply.send([]); const branches = String(r.stdout || '').split('\n') .map((l) => (l.split('\t')[1] || '').replace('refs/heads/', '').trim()) .filter(Boolean) .sort(); return reply.send(branches); }); app.post('/projects/:name/multilabel', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const on = String((req.body && req.body.multi_label) || '') === 'on'; db.setProjectMultiLabel(p.name, on); if (on) { /* Nuxt-label preset: cheap health probe (Varnish probes HEALTH_PATH on the app port every 5s — '/' was a full homepage SSR render each time) and sitemap-driven post-deploy warming (the deploy ban empties the long-TTL article cache; warming only '/' left every article cold). */ const r = await paas.paasSet(p.name, { health: '/api/_health', warmSitemap: '/news-sitemap.xml' }); if (r.code !== 0) { return reply.redirect(`/projects/${p.name}?err=${encodeURIComponent(`Multi-label enabled, but applying the health/warm preset failed: ${tailStr(r.stderr || 'paas set failed')}`)}`); } } return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(`Multi-label ${on ? 'enabled (health probe → /api/_health, post-deploy warm → /news-sitemap.xml)' : 'disabled'} — label env preset and domain→BAN_HOSTS sync are ${on ? 'active' : 'off'} for this project`)}`); }); app.post('/projects/:name/autodeploy', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const on = String((req.body && req.body.auto_deploy) || '') === 'on'; db.setProjectAutoDeploy(p.name, on); return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(on ? `Automatic deploys enabled — pushes to ${p.branch} deploy again` : 'Automatic deploys disabled — webhook pushes are ignored until re-enabled; the Deploy button still works')}`); }); app.post('/projects/:name/branch', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const branch = String((req.body && req.body.branch) || '').trim(); if (!BRANCH_RE.test(branch)) return reply.redirect(`/projects/${p.name}?err=Invalid+branch+name`); if (branch === p.branch) return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(`Branch is already ${branch}`)}`); // Deliberately no deploy here: the new branch is used by the next // deployment (manual or webhook push to that branch), nothing is rolled // out just by changing the setting. db.setProjectBranch(p.name, branch); return reply.redirect(`/projects/${p.name}?msg=${encodeURIComponent(`Branch set to ${branch} — takes effect on the next deployment; webhook pushes to ${branch} now trigger deploys${p.auto_deploy ? '' : ' (once Auto-deploy is on)'}`)}`); }); app.post('/projects/:name/appconf', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const pick = (v) => (v === 'on' || v === 'off' ? v : undefined); const flags = { cache: pick(req.body && req.body.cache), noindex: pick(req.body && req.body.noindex), geoExemptGoogle: pick(req.body && req.body.geo_exempt_google), }; if (!flags.cache && !flags.noindex && !flags.geoExemptGoogle) return reply.redirect(`/projects/${p.name}?err=Nothing+to+change`); const r = await paas.paasSet(p.name, flags); const q = r.code === 0 ? `msg=${encodeURIComponent('App flags updated — Caddy/Varnish regenerated. Apps that bake NUXT_EDGE_CACHE at build time need a redeploy for a cache change to fully apply.')}` : `err=${encodeURIComponent(tailStr(r.stderr || 'failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); const GEO_CC_RE = /^[A-Z]{2}$/; const GEO_PATH_RE = /^\/[A-Za-z0-9_\-./*]*$/; app.post('/projects/:name/geo', async (req, reply) => { const p = findProject(req, reply); if (!p) return; let rules; try { rules = JSON.parse(String((req.body && req.body.rules_json) || '[]')); } catch { return reply.redirect(`/projects/${p.name}?err=Invalid+geo+rules`); } if (!Array.isArray(rules) || rules.length > 50) { return reply.redirect(`/projects/${p.name}?err=Invalid+geo+rules`); } for (const r of rules) { const okMode = r.mode === 'block' || r.mode === 'allow-only'; const okCountries = Array.isArray(r.countries) && r.countries.length > 0 && r.countries.length <= 250 && r.countries.every((c) => GEO_CC_RE.test(String(c))); const okPaths = Array.isArray(r.paths) && r.paths.length <= 20 && r.paths.every((x) => GEO_PATH_RE.test(String(x))); if (!okMode || !okCountries || !okPaths) { return reply.redirect(`/projects/${p.name}?err=Invalid+geo+rule`); } } const r = rules.length ? await paas.paasGeoSet(p.name, JSON.stringify({ rules })) : await paas.runCmd(config.paasBin, ['geo', p.name, '--clear'], { timeout: 60000 }); const q = r.code === 0 ? 'msg=Geo+rules+updated' : `err=${encodeURIComponent(tailStr(r.stderr || 'geo update failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/domain-rules', async (req, reply) => { const p = findProject(req, reply); if (!p) return; let rules; try { rules = JSON.parse(String((req.body && req.body.rules_json) || '[]')); } catch { return reply.redirect(`/projects/${p.name}?err=Invalid+domain+rules`); } if (!Array.isArray(rules) || rules.length > 50) { return reply.redirect(`/projects/${p.name}?err=Invalid+domain+rules`); } const info = await paas.paasInfo(p.name); const knownDomains = new Set( String((info && info.DOMAINS) || '').split(',').map((s) => s.trim()).filter(Boolean), ); const seen = new Set(); for (const r of rules) { const dom = String(r && r.domain || '').trim().toLowerCase(); const okDomain = DOMAIN_RE.test(dom) && knownDomains.has(dom) && !seen.has(dom); const okPaths = Array.isArray(r.paths) && r.paths.length <= 20 && r.paths.every((x) => GEO_PATH_RE.test(String(x))); const okNoindex = r.noindex === undefined || typeof r.noindex === 'boolean'; if (!okDomain || !okPaths || !okNoindex) { return reply.redirect(`/projects/${p.name}?err=Invalid+domain+rule`); } seen.add(dom); } const r = rules.length ? await paas.paasDomainRulesSet(p.name, JSON.stringify({ rules: rules.map((x) => ({ domain: String(x.domain).trim().toLowerCase(), paths: x.paths || [], noindex: Boolean(x.noindex), })), })) : await paas.runCmd(config.paasBin, ['domain-rules', p.name, '--clear'], { timeout: 60000 }); const q = r.code === 0 ? 'msg=Domain+rules+updated' : `err=${encodeURIComponent(tailStr(r.stderr || 'domain rules update failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/ssl-retry', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const domain = String((req.body && req.body.domain) || '').trim().toLowerCase(); if (domain && !DOMAIN_RE.test(domain)) { return reply.redirect(`/projects/${p.name}?err=Invalid+domain`); } const r = await paas.paasSsl(p.name, domain || undefined); const summary = (r.stdout || '').split('\n') .filter((l) => l.includes('OK:') || l.includes('PENDING:')) .map((l) => l.replace(/^\[paas\]\s*/, '').replace(/\s*\(valid until.*\)/, '')) .join(' · ') || (r.code === 0 ? 'retriggered' : 'failed'); const q = r.code === 0 ? `msg=${encodeURIComponent(`SSL retry: ${summary}`.slice(0, 300))}` : `err=${encodeURIComponent(tailStr(r.stderr || 'ssl retry failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/git-verify', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const acc0 = p.git_account_id ? db.getGitAccount(p.git_account_id) : null; let acc = acc0 && acc0.owner_user_id === req.user.id ? acc0 : null; if (acc) acc = await git.freshAccount(acc); if (!acc || !p.repo_full) { return reply.redirect(`/projects/${p.name}?err=No+git+account+linked+to+this+project`); } const parts = []; const pubKey = readDeployKey(); if (pubKey) { const kr = await git.ensureDeployKey(acc, p.repo_full, `paas ${config.baseUrl}`, pubKey); parts.push(`deploy key ${kr.ok ? (kr.existed ? 'OK' : 'added') : 'FAILED: ' + kr.error}`); } else { parts.push('deploy key MISSING on server'); } const hr = await git.ensureWebhook(acc, p.repo_full, `${config.baseUrl}/hooks/${p.name}/${p.webhook_secret}`, p.webhook_secret); parts.push(`webhook ${hr.ok ? (hr.existed ? 'OK' : 'added') : 'FAILED: ' + hr.error}`); return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent(`Git integration: ${parts.join(' · ')}`)); }); // Attach/change a project's repository after creation — the "Git // integration" card only ever appears for projects created with an account // picked at creation time (p.git_account_id), so a ZIP-uploaded project had // no way back into that flow. Accepts either a connected account + repo_full // (auto-provisions deploy key + webhook, same as the creation flow) or a // plain repo_url for manual/generic git hosts (Webhook + Deploy key cards // already handle that path — this just needed a place to persist the URL). app.post('/projects/:name/repo', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const b = req.body || {}; const accId = Number(b.git_account_id || 0); const repoFull = String(b.repo_full || '').trim(); const manualUrl = String(b.repo_url || '').trim(); if (accId && repoFull) { if (!/^[\w.-]+(\/[\w.-]+)+$/.test(repoFull)) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent('Invalid repository path.')); } const acc0 = db.getGitAccount(accId); let acc = acc0 && acc0.owner_user_id === req.user.id ? acc0 : null; if (!acc) return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent('Unknown git account.')); acc = await git.freshAccount(acc); db.setProjectGit(p.name, acc.id, repoFull); const parts = []; const pubKey = readDeployKey(); if (pubKey) { const kr = await git.ensureDeployKey(acc, repoFull, `paas ${config.baseUrl}`, pubKey); parts.push(`deploy key ${kr.ok ? (kr.existed ? 'already set' : 'added') : 'FAILED: ' + kr.error}`); } else { parts.push('deploy key MISSING on server'); } const hr = await git.ensureWebhook(acc, repoFull, `${config.baseUrl}/hooks/${p.name}/${p.webhook_secret}`, p.webhook_secret); parts.push(`webhook ${hr.ok ? (hr.existed ? 'already set' : 'added') : 'FAILED: ' + hr.error}`); return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent(`Repository connected — ${parts.join(' · ')}`)); } if (manualUrl) { if (!REPO_RE.test(manualUrl)) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent('Invalid repository URL (https://…, ssh://… or git@…).')); } db.setProjectGit(p.name, null, null); db.setProjectRepoUrl(p.name, manualUrl); return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent('Repository URL saved — add the webhook and deploy key shown below to the repository to enable push deploys.')); } return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent('Provide a repository URL, or pick a connected account and repository.')); }); app.post('/projects/:name/node', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const v = String((req.body && req.body.node_version) || ''); if (!/^(system|\d{1,3})$/.test(v)) return reply.redirect(`/projects/${p.name}?err=Invalid+node+version`); const r = await paas.paasNodeSet(p.name, v); const q = r.code === 0 ? `msg=${encodeURIComponent(`Node version set to ${v} — takes effect on next deploy/restart`)}` : `err=${encodeURIComponent(tailStr(r.stderr || 'failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/access', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const action = String((req.body && req.body.action) || ''); let r; if (action === 'enable' || action === 'disable') { r = await paas.paasEnable(p.name, action === 'enable'); } else if (action === 'protect') { const buser = String((req.body && req.body.ba_user) || '').trim(); const bpass = String((req.body && req.body.ba_password) || ''); if (!/^[A-Za-z0-9_.-]{2,40}$/.test(buser) || bpass.length < 4) { return reply.redirect(`/projects/${p.name}?err=Protection+needs+a+user+and+a+password+(min+4+chars)`); } r = await paas.paasProtect(p.name, buser, bpass); } else if (action === 'unprotect') { r = await paas.paasUnprotect(p.name); } else { return reply.redirect(`/projects/${p.name}?err=Unknown+action`); } const q = r.code === 0 ? `msg=${encodeURIComponent(`Access updated (${action})`)}` : `err=${encodeURIComponent(tailStr(r.stderr || 'access change failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/cache/purge', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const r = await paas.paasBan(p.name); const q = r.code === 0 ? 'msg=Cache+purged' : `err=${encodeURIComponent(tailStr(r.stderr || 'purge failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/cache/warm', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const r = await paas.paasWarm(p.name); const q = r.code === 0 ? 'msg=Cache+warmed' : `err=${encodeURIComponent(tailStr(r.stderr || 'warm failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/rollback', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const res = await paas.paasRollback(p.name); if (res.code !== 0) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`Rollback failed: ${tailStr(res.output) || 'exit ' + res.code}`)); } return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent('Rolled back to the previous release.')); }); app.post('/projects/:name/remove', async (req, reply) => { const p = findProject(req, reply); if (!p) return; const res = await paas.paasRemove(p.name); if (res.code !== 0) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`paas remove failed: ${tailStr(res.output) || 'exit ' + res.code}`)); } db.deleteDeploymentsByApp(p.name); db.deleteTrafficByApp(p.name); db.deleteProject(p.name); // wipe every remaining artifact: build workspace, snapshot, deploy logs, // caddy access logs (+rotations). All best-effort. const rmrf = (target) => { try { fs.rmSync(target, { recursive: true, force: true }); } catch { /* ignore */ } }; rmrf(path.join(config.buildDir, p.name)); rmrf(snapshotPath(p.name)); try { const logRe = new RegExp(`^deploy-${p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+\\.log$`); for (const f of fs.readdirSync(config.logDir)) { if (logRe.test(f)) rmrf(path.join(config.logDir, f)); } } catch { /* ignore */ } try { const cdir = '/srv/paas/logs/caddy'; for (const f of fs.readdirSync(cdir)) { if (f === `${p.name}.access.log` || f.startsWith(`${p.name}.access.log.`) || f.startsWith(`${p.name}.access-`)) rmrf(path.join(cdir, f)); } } catch { /* ignore */ } return reply.redirect('/?msg=' + encodeURIComponent(`Project ${p.name} removed — app, cache, files, logs and history wiped.`)); }); /* Multi-label env preset: NUXT_SITE = project name (label code = app name by convention), fresh purge secret, this app's own domain; the frontend-admin trio is SHARED across label apps and can be copied from a sibling project (?from=, owner-checked). Returned as JSON for the panel to fill into the env editor — the user reviews and saves explicitly. */ app.get('/projects/:name/env-template', async (req, reply) => { const p = findProject(req, reply); if (!p) return; if (!p.multi_label) return reply.code(404).send({ error: 'not a multi-label project' }); const info = await paas.paasInfo(p.name); const domain = info && info.DOMAINS ? String(info.DOMAINS).split(',')[0].trim() : (info && info.SYSTEM_DOMAIN ? String(info.SYSTEM_DOMAIN).trim() : ''); const secret = randomBytes(24).toString('hex'); const from = String((req.query && req.query.from) || '').trim(); let shared = ''; if (from && NAME_RE.test(from)) { const src = db.getProject(from); if (src && (req.user.role === 'admin' || src.owner_user_id === req.user.id)) { const KEEP = ['NUXT_ADMIN_PASSWORD', 'NUXT_ADMIN_SESSION_SECRET', 'NUXT_STRAPI_WRITE_TOKEN']; const lines = String(paas.readAppEnv(from)).split('\n') .filter((l) => KEEP.some((k) => l.startsWith(`${k}=`))); if (lines.length) shared = `\n# frontend admin mode (slot editor) — shared, copied from ${from}\n${lines.join('\n')}\n`; } } const env = [ `NUXT_SITE=${p.name}`, 'NUXT_EDGE_CACHE=varnish', `NUXT_PURGE_SECRET=${secret}`, `NUXT_BAN_HOSTS=${domain}`, 'NUXT_VARNISH_ADDR=127.0.0.1:6081', ].join('\n') + '\n' + shared; return reply.send({ env, purgeSecret: secret, webhookUrl: domain ? `https://${domain}/api/_hooks/cms` : '', }); }); app.post('/projects/:name/env', async (req, reply) => { const p = findProject(req, reply); if (!p) return; try { paas.writeAppEnv(p.name, String((req.body && req.body.env) || '')); } catch (err) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`Saving .env failed: ${err.message}`)); } const note = p.runtime === 'laravel' ? 'Environment saved. Laravel reads it from its config cache — click Restart (rebuilds the cache) or deploy.' : 'Environment saved. Takes effect on the next deploy — or restart the app.'; return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent(note)); }); app.post('/projects/:name/restart', async (req, reply) => { const p = findProject(req, reply); if (!p) return; // paas restart: node = pm2 restart --update-env; laravel = artisan // config/route/view cache rebuild + php-fpm reload + queue:restart const res = await paas.paasRestart(p.name); if (res.code !== 0) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`Restart failed: ${tailStr(res.output) || 'exit ' + res.code}`)); } return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent(p.runtime === 'laravel' ? 'App restarted — config cache rebuilt from the current environment, PHP-FPM reloaded.' : 'App restarted with updated environment.')); }); // ---------- laravel: php version + switches ---------- app.post('/projects/:name/php', async (req, reply) => { const p = findProject(req, reply); if (!p) return; if (p.runtime !== 'laravel') return reply.redirect(`/projects/${p.name}?err=Not+a+Laravel+project`); const v = String((req.body && req.body.php_version) || ''); if (!paas.capabilities().phpVersions.includes(v)) return reply.redirect(`/projects/${p.name}?err=PHP+version+not+installed`); const r = await paas.paasPhpSet(p.name, v); const q = r.code === 0 ? `msg=${encodeURIComponent(`PHP ${v} active for this app — caches are rebuilt on the next deploy or Restart`)}` : `err=${encodeURIComponent(tailStr(r.stderr || r.output || 'failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); app.post('/projects/:name/laravel', async (req, reply) => { const p = findProject(req, reply); if (!p) return; if (p.runtime !== 'laravel') return reply.redirect(`/projects/${p.name}?err=Not+a+Laravel+project`); const b = req.body || {}; const flags = {}; for (const k of ['migrate', 'queue', 'scheduler']) { if (b[k] === 'on' || b[k] === 'off') flags[k] = b[k]; } if (/^\d{1,3}$/.test(String(b.max_children || '')) && Number(b.max_children) >= 1) flags.maxChildren = String(b.max_children); if (!Object.keys(flags).length) return reply.redirect(`/projects/${p.name}?err=Nothing+to+change`); const r = await paas.paasSet(p.name, flags); const what = Object.entries(flags).map(([k, v]) => `${k}=${v}`).join(', '); const q = r.code === 0 ? `msg=${encodeURIComponent(`Laravel settings updated (${what})`)}` : `err=${encodeURIComponent(tailStr(r.stderr || r.output || 'failed'))}`; return reply.redirect(`/projects/${p.name}?${q}`); }); // ---------- database (MariaDB, any runtime) ---------- app.post('/projects/:name/db', async (req, reply) => { const p = findProject(req, reply); if (!p) return; if (!paas.capabilities().db) return reply.redirect(`/projects/${p.name}?err=No+database+server+on+this+host`); const action = String((req.body && req.body.action) || ''); if (!['create', 'backup', 'drop'].includes(action)) return reply.redirect(`/projects/${p.name}?err=Unknown+database+action`); if (action === 'drop' && String((req.body && req.body.confirm) || '') !== p.name) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`Type the project name (${p.name}) to confirm dropping its database.`)); } const r = await paas.paasDb(p.name, action, { forceEnv: Boolean(req.body && req.body.force_env) }); if (r.code !== 0) { return reply.redirect(`/projects/${p.name}?err=` + encodeURIComponent(`Database ${action} failed: ${tailStr(r.output) || 'exit ' + r.code}`)); } const msgs = { create: p.runtime === 'laravel' ? 'Database ready — DB_* written to the environment. Click Restart (or deploy) so the config cache picks it up.' : 'Database ready — DB_* and DATABASE_URL written to the environment. Restart the app to pick them up.', backup: 'Database dump written to /srv/paas/backups/db (kept: last 7).', drop: 'Database dropped (a final dump was kept in /srv/paas/backups/db); DB_* removed from the environment.', }; return reply.redirect(`/projects/${p.name}?msg=` + encodeURIComponent(msgs[action])); }); app.get('/projects/:name/logs', async (req, reply) => { const p = findProject(req, reply); if (!p) return; let content; if (p.runtime === 'laravel') { // storage/logs/{laravel*,php-fpm,scheduler}.log (+ the queue worker's pm2 log if enabled) content = paas.readRuntimeLog(p.name) || '(no log files yet under shared/storage/logs)'; const info = await paas.paasInfoCached(p.name); if (info && String(info.QUEUE_WORKER) === 'on') { const q = await paas.pm2Logs(`${p.name}-queue`); if (q.code === 0 && q.output.trim()) content += `\n\n==> queue worker (pm2 ${p.name}-queue) <==\n${q.output}`; } } else { const res = await paas.pm2Logs(p.name); content = res.code === 0 ? (res.output || '(no output)') : `pm2 logs failed (exit ${res.code}):\n${res.output}`; } return reply.view('logs.ejs', { user: req.user, title: `${p.name} logs`, name: p.name, content }); }); // ---------- deploy key ---------- app.get('/deploy-key', async (req, reply) => { return reply.view('deploykey.ejs', { user: req.user, title: 'Deploy key', deployKey: readDeployKey(), keyPath: config.deployKeyPub }); }); // ---------- homepage snapshots ---------- app.get('/snapshots/:name.png', async (req, reply) => { const name = String(req.params.name || ''); if (!NAME_RE.test(name)) return reply.code(404).send('Not found'); let data; try { data = await fs.promises.readFile(snapshotPath(name)); } catch { return reply.code(404).send('Not found'); } reply.header('cache-control', 'no-cache'); return reply.type('image/png').send(data); }); // ---------- deployment log ---------- app.get('/deployments/:id', async (req, reply) => { const id = Number(req.params.id); const dep = Number.isInteger(id) && id > 0 ? db.getDeployment(id) : null; if (!dep) return reply.code(404).send('Deployment not found'); const proj = db.getProject(dep.app); if (proj && req.user.role !== 'admin' && proj.owner_user_id !== req.user.id) { return reply.code(404).send('Deployment not found'); } const live = ACTIVE.includes(dep.status); const content = live ? '' : readLogTail(dep.log_path); const fix = !live && dep.status === 'failed' && proj ? detectBuildFix(content) : null; return reply.view('deployment.ejs', { user: req.user, title: `Deployment #${dep.id}`, dep, live, content, fix, msg: req.query.msg || null, err: req.query.err || null, }); }); app.get('/deployments/:id/log/stream', (req, reply) => { const id = Number(req.params.id); const dep = Number.isInteger(id) && id > 0 ? db.getDeployment(id) : null; if (!dep || !dep.log_path) return reply.code(404).send({ error: 'not found' }); reply.hijack(); const res = reply.raw; res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform', connection: 'keep-alive', 'x-accel-buffering': 'no', }); res.write(':stream start\n\n'); const tail = spawn('tail', ['-n', '+1', '-F', dep.log_path], { stdio: ['ignore', 'pipe', 'ignore'] }); let buf = ''; let closed = false; const flushLine = (line) => { try { res.write(`data: ${line.replace(/\r/g, '')}\n\n`); } catch { /* client gone */ } }; tail.stdout.on('data', (chunk) => { buf += chunk.toString('utf8'); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) flushLine(line); }); const cleanup = () => { if (closed) return; closed = true; clearInterval(poll); clearInterval(ping); try { tail.kill('SIGKILL'); } catch { /* already gone */ } try { res.end(); } catch { /* already closed */ } }; const poll = setInterval(() => { const row = db.getDeployment(id); const status = row ? row.status : 'failed'; if (!ACTIVE.includes(status)) { clearInterval(poll); // Give tail a moment to flush the final lines before ending the stream. setTimeout(() => { if (closed) return; if (buf) { flushLine(buf); buf = ''; } try { res.write(`event: done\ndata: ${status}\n\n`); } catch { /* client gone */ } cleanup(); }, 800); } }, 1000); const ping = setInterval(() => { if (!closed) { try { res.write(':ping\n\n'); } catch { /* client gone */ } } }, 15000); req.raw.on('close', cleanup); tail.on('error', cleanup); }); // ---------- checkmk agent-over-HTTPS (Bearer token; pulled by the // monitoring server as a datasource program — no LAN route needed) ---------- let cmkCache = { at: 0, out: '' }; app.get('/cmk-agent', async (req, reply) => { const token = config.cmkAgentToken; const authHeader = String(req.headers.authorization || ''); if (!token || !auth.safeEqual(authHeader, `Bearer ${token}`)) { return reply.code(401).send('unauthorized'); } if (Date.now() - cmkCache.at < 30000 && cmkCache.out) { return reply.type('text/plain').send(cmkCache.out); } const res = await paas.runCmd('sudo', ['/usr/bin/check_mk_agent'], { timeout: 25000 }); if (res.code !== 0 || !res.stdout) { return reply.code(500).send(`agent failed: ${(res.stderr || '').slice(-200)}`); } cmkCache = { at: Date.now(), out: res.stdout }; return reply.type('text/plain').send(res.stdout); }); // Site list for Checkmk website monitoring (same Bearer token) — the // monitoring server syncs these into per-domain hosts with HTTPS/cert checks. app.get('/cmk-sites', async (req, reply) => { const token = config.cmkAgentToken; const authHeader = String(req.headers.authorization || ''); if (!token || !auth.safeEqual(authHeader, `Bearer ${token}`)) { return reply.code(401).send('unauthorized'); } return reply.send({ sites: paas.listServedSites() }); }); // ---------- webhooks (no session auth; signature/secret verified) ---------- app.post('/hooks/:project/:secret', async (req, reply) => { const name = String(req.params.project || ''); if (!NAME_RE.test(name)) return reply.code(404).send({ error: 'unknown project' }); const project = db.getProject(name); if (!project) return reply.code(404).send({ error: 'unknown project' }); const raw = req.rawBody || Buffer.alloc(0); if (!verifyWebhook(req.headers, raw, String(req.params.secret || ''), project.webhook_secret)) { return reply.code(401).send({ error: 'bad signature' }); } if (req.headers['x-github-event'] === 'ping') return reply.send({ ok: true, pong: true }); if (!project.auto_deploy) { return reply.send({ ignored: true, reason: 'automatic deploys are disabled for this project' }); } const { branch, commit } = extractPush(req.body); if (!branch || branch !== project.branch) { return reply.send({ ignored: true, reason: `push to "${branch || 'unknown'}" ignored — project deploys "${project.branch}"` }); } const id = enqueueDeploy(project, { source: 'webhook', commit }); return reply.send({ ok: true, queued: true, deployment: id }); }); // ---------- misc ---------- app.setNotFoundHandler((req, reply) => { if (req.url.startsWith('/api/') || req.url.startsWith('/hooks/')) { return reply.code(404).send({ error: 'not found' }); } return reply.code(404).send('Not found'); }); // ---------- start ---------- fs.mkdirSync(config.buildDir, { recursive: true }); fs.mkdirSync(config.logDir, { recursive: true }); fs.mkdirSync(config.snapshotDir, { recursive: true }); const stale = db.markStale(); if (stale.changes > 0) { app.log.warn(`marked ${stale.changes} stale deployment(s) as failed (panel restarted)`); } try { await app.listen({ port: config.port, host: config.host }); } catch (err) { app.log.error(err); process.exit(1); } for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { app.close().finally(() => process.exit(0)); }); }