| 1 | /**
|
|---|
| 2 | * Admin: Updates (god-only).
|
|---|
| 3 | * GET /admin/updates -> current vs. latest version + status
|
|---|
| 4 | * POST /admin/updates/run -> fetch latest + restart (fleet only; see below)
|
|---|
| 5 | *
|
|---|
| 6 | * Three topologies are supported, detected automatically:
|
|---|
| 7 | * - CHECKOUT (external self-hoster): the app dir is itself a git clone with
|
|---|
| 8 | * origin = GitHub. "Latest" = origin/<branch> (fetched on view). Updating is
|
|---|
| 9 | * done out-of-band by `klonkt-update` (needs root for systemd), so the page
|
|---|
| 10 | * shows that command instead of an in-app button.
|
|---|
| 11 | * - BARE (Robin's own VPS fleet): a bare repo at KLONKT_GIT_DIR; the app dir is
|
|---|
| 12 | * a `checkout -f` work-tree (no .git). "Latest" = <branch>. The detached
|
|---|
| 13 | * self-update script runs in-app (no root needed) → the button works.
|
|---|
| 14 | * - ANDROID (the Klonkt phone app, Termux — node reports platform 'android'):
|
|---|
| 15 | * installed from a prebuilt tarball, no git at all. "Latest" = the package
|
|---|
| 16 | * version on the klonkt STABLE branch (the channel the phone tarballs are
|
|---|
| 17 | * built from). The button runs the phone's own `klonkt-update` command
|
|---|
| 18 | * detached, which survives the server restart it causes.
|
|---|
| 19 | * git stderr is ignored so a foreign/missing repo never spams "fatal: ...".
|
|---|
| 20 | */
|
|---|
| 21 |
|
|---|
| 22 | import express from 'express';
|
|---|
| 23 | import { execFileSync, spawn } from 'child_process';
|
|---|
| 24 | import fs from 'fs';
|
|---|
| 25 | import path from 'path';
|
|---|
| 26 | import { renderPage } from '../middleware/render.js';
|
|---|
| 27 | import { requireGod } from '../middleware/auth.js';
|
|---|
| 28 |
|
|---|
| 29 | const router = express.Router();
|
|---|
| 30 |
|
|---|
| 31 | const HOME = process.env.HOME || '';
|
|---|
| 32 | const APP_DIR = process.cwd();
|
|---|
| 33 | const BRANCH = process.env.KLONKT_BRANCH || 'main';
|
|---|
| 34 | const GIT_DIR = process.env.KLONKT_GIT_DIR || path.join(HOME, 'git-repos/prutfolio.git');
|
|---|
| 35 | const UPDATE_SCRIPT = process.env.KLONKT_UPDATE_SCRIPT || path.join(HOME, 'bin/klonkt-self-update.sh');
|
|---|
| 36 |
|
|---|
| 37 | // The app dir is a git CHECKOUT (GitHub install) when it has a .git; otherwise we
|
|---|
| 38 | // fall back to the BARE repo (fleet). This split keeps the version check pointed at
|
|---|
| 39 | // a repo that actually exists, so it never logs "fatal: not a git repository".
|
|---|
| 40 | const IS_CHECKOUT = (() => { try { return fs.existsSync(path.join(APP_DIR, '.git')); } catch { return false; } })();
|
|---|
| 41 | const REMOTE_REF = IS_CHECKOUT ? `origin/${BRANCH}` : BRANCH; // what "latest" resolves to
|
|---|
| 42 |
|
|---|
| 43 | // The Klonkt Android app (Termux): node there reports platform 'android'; the
|
|---|
| 44 | // filesystem check is belt-and-braces for exotic node builds.
|
|---|
| 45 | const IS_ANDROID = process.platform === 'android'
|
|---|
| 46 | || (() => { try { return fs.existsSync('/data/data/com.termux/files/usr/bin'); } catch { return false; } })();
|
|---|
| 47 | // Phone tarballs are built from the stable branch → that's the phone's "latest".
|
|---|
| 48 | const ANDROID_LATEST_URL = 'https://raw.githubusercontent.com/roboburr/klonkt/stable/package.json';
|
|---|
| 49 |
|
|---|
| 50 | async function androidLatestVersion() {
|
|---|
| 51 | try {
|
|---|
| 52 | const ctl = new AbortController();
|
|---|
| 53 | const t = setTimeout(() => ctl.abort(), 8000);
|
|---|
| 54 | const r = await fetch(ANDROID_LATEST_URL, { signal: ctl.signal });
|
|---|
| 55 | clearTimeout(t);
|
|---|
| 56 | if (!r.ok) return null;
|
|---|
| 57 | return (await r.json()).version || null;
|
|---|
| 58 | } catch { return null; }
|
|---|
| 59 | }
|
|---|
| 60 |
|
|---|
| 61 | function appVersion() {
|
|---|
| 62 | try { return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'package.json'), 'utf8')).version || null; }
|
|---|
| 63 | catch { return null; }
|
|---|
| 64 | }
|
|---|
| 65 | // stderr is ignored on purpose → a missing/foreign repo fails silently (returns null).
|
|---|
| 66 | function git(args) {
|
|---|
| 67 | try {
|
|---|
| 68 | const base = IS_CHECKOUT ? ['-C', APP_DIR] : ['--git-dir', GIT_DIR];
|
|---|
| 69 | return execFileSync('git', [...base, ...args], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|---|
| 70 | } catch { return null; }
|
|---|
| 71 | }
|
|---|
| 72 | function currentSha() {
|
|---|
| 73 | if (IS_CHECKOUT) return git(['rev-parse', 'HEAD']);
|
|---|
| 74 | try { return fs.readFileSync(path.join(APP_DIR, '.klonkt-version'), 'utf8').trim() || null; } catch { return null; }
|
|---|
| 75 | }
|
|---|
| 76 |
|
|---|
| 77 | // Last 5 commits = the "recent changes" you'll get when updating.
|
|---|
| 78 | function recentChanges() {
|
|---|
| 79 | const out = git(['log', '-5', '--format=%s%x1f%cd', '--date=short', REMOTE_REF]);
|
|---|
| 80 | if (!out) return [];
|
|---|
| 81 | return out.split('\n').map((l) => {
|
|---|
| 82 | const i = l.indexOf('\x1f');
|
|---|
| 83 | return i >= 0 ? { msg: l.slice(0, i), date: l.slice(i + 1) } : { msg: l, date: '' };
|
|---|
| 84 | });
|
|---|
| 85 | }
|
|---|
| 86 |
|
|---|
| 87 | router.get('/', requireGod, async (req, res) => {
|
|---|
| 88 | // ANDROID: version-based check against the stable branch; the update button
|
|---|
| 89 | // runs the phone's klonkt-update (always present, the start script writes it).
|
|---|
| 90 | if (IS_ANDROID) {
|
|---|
| 91 | const cur = appVersion();
|
|---|
| 92 | const latest = await androidLatestVersion();
|
|---|
| 93 | return renderPage(req, res, 'pages/admin-updates', {
|
|---|
| 94 | pageTitleKey: 'admin.t_updates',
|
|---|
| 95 | bodyClass: 'on-admin',
|
|---|
| 96 | appVersion: cur,
|
|---|
| 97 | currentSha: cur ? 'v' + cur : null,
|
|---|
| 98 | currentDesc: null,
|
|---|
| 99 | latestSha: latest ? 'v' + latest : null,
|
|---|
| 100 | latestDesc: null,
|
|---|
| 101 | upToDate: !!(cur && latest && cur === latest),
|
|---|
| 102 | canCheck: !!latest,
|
|---|
| 103 | canSelfUpdate: true,
|
|---|
| 104 | manualCommand: null,
|
|---|
| 105 | behind: null,
|
|---|
| 106 | changes: [],
|
|---|
| 107 | success: req.query.success || null,
|
|---|
| 108 | error: req.query.error || null,
|
|---|
| 109 | });
|
|---|
| 110 | }
|
|---|
| 111 | // For a GitHub checkout, refresh the remote ref so "latest" is current. Quiet +
|
|---|
| 112 | // shallow; offline just leaves the last-known ref. stderr ignored (no log noise).
|
|---|
| 113 | if (IS_CHECKOUT) {
|
|---|
| 114 | try { execFileSync('git', ['-C', APP_DIR, 'fetch', '--quiet', '--depth', '1', 'origin', BRANCH], { timeout: 20000, stdio: 'ignore' }); } catch { /* offline / no remote */ }
|
|---|
| 115 | }
|
|---|
| 116 | const cur = currentSha();
|
|---|
| 117 | const latest = git(['rev-parse', REMOTE_REF]);
|
|---|
| 118 | // The in-app "Update now" button only works with the detached self-update script
|
|---|
| 119 | // (the fleet). A systemd install updates via `klonkt-update` (root) → show that.
|
|---|
| 120 | const canSelfUpdate = (() => { try { return fs.existsSync(UPDATE_SCRIPT); } catch { return false; } })();
|
|---|
| 121 | renderPage(req, res, 'pages/admin-updates', {
|
|---|
| 122 | pageTitleKey: 'admin.t_updates',
|
|---|
| 123 | bodyClass: 'on-admin',
|
|---|
| 124 | appVersion: appVersion(),
|
|---|
| 125 | currentSha: cur ? cur.slice(0, 8) : null,
|
|---|
| 126 | currentDesc: cur ? git(['log', '-1', '--format=%s · %cd', '--date=short', cur]) : null,
|
|---|
| 127 | latestSha: latest ? latest.slice(0, 8) : null,
|
|---|
| 128 | latestDesc: latest ? git(['log', '-1', '--format=%s · %cd', '--date=short', REMOTE_REF]) : null,
|
|---|
| 129 | upToDate: !!(cur && latest && cur === latest),
|
|---|
| 130 | canCheck: !!latest,
|
|---|
| 131 | canSelfUpdate,
|
|---|
| 132 | manualCommand: (!canSelfUpdate && IS_CHECKOUT) ? 'sudo klonkt-update' : null,
|
|---|
| 133 | behind: (cur && latest && cur !== latest) ? git(['rev-list', '--count', cur + '..' + REMOTE_REF]) : null,
|
|---|
| 134 | changes: recentChanges(),
|
|---|
| 135 | success: req.query.success || null,
|
|---|
| 136 | error: req.query.error || null,
|
|---|
| 137 | });
|
|---|
| 138 | });
|
|---|
| 139 |
|
|---|
| 140 | router.post('/run', requireGod, (req, res) => {
|
|---|
| 141 | // ANDROID: run the phone's updater detached. It kills node (this process),
|
|---|
| 142 | // swaps the code while keeping storage/.env, and restarts everything — the
|
|---|
| 143 | // detached shell survives the pkill because it isn't a node process.
|
|---|
| 144 | if (IS_ANDROID) {
|
|---|
| 145 | try {
|
|---|
| 146 | const child = spawn('bash', ['-c', 'klonkt-update >> "$HOME/klonkt-update.log" 2>&1'], { detached: true, stdio: 'ignore' });
|
|---|
| 147 | child.unref();
|
|---|
| 148 | } catch (e) {
|
|---|
| 149 | return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
|
|---|
| 150 | }
|
|---|
| 151 | return res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site is ~1 minuut bezig (downloaden + herstarten). Ververs daarna deze pagina.'));
|
|---|
| 152 | }
|
|---|
| 153 | if (!fs.existsSync(UPDATE_SCRIPT)) {
|
|---|
| 154 | return res.redirect('/admin/updates?error=' + encodeURIComponent('In-app updaten is hier niet beschikbaar — werk bij met `klonkt-update` op de server.'));
|
|---|
| 155 | }
|
|---|
| 156 | try {
|
|---|
| 157 | // Detached + unlinked: survives the reload that restarts this app.
|
|---|
| 158 | const child = spawn('bash', [UPDATE_SCRIPT, APP_DIR], { detached: true, stdio: 'ignore' });
|
|---|
| 159 | child.unref();
|
|---|
| 160 | } catch (e) {
|
|---|
| 161 | return res.redirect('/admin/updates?error=' + encodeURIComponent('Kon update niet starten: ' + (e.message || e)));
|
|---|
| 162 | }
|
|---|
| 163 | res.redirect('/admin/updates?success=' + encodeURIComponent('Bijwerken gestart — de site herstart over ~10 seconden. Ververs daarna deze pagina.'));
|
|---|
| 164 | });
|
|---|
| 165 |
|
|---|
| 166 | export default router;
|
|---|