Files
Lauri Ojansivu 31e569a18e Single-file WeKan for every platform with a Node.js bundle.
One executable per platform: that platform's published bundle repackaged
as a Node.js single executable application whose ZIP Node 26.11 mounts as
a virtual file system (useVfs + vfsArchive). Only FerretDB is copied out,
once and verified, to wekan-files/bin; native addons load from the VFS.

Everything WeKan writes is in wekan-files beside the executable, which
WRITABLE_PATH points at: attachments, avatars, db, bin, backup, logs,
versions. WeKan now treats a WRITABLE_PATH named wekan-files as the files
root (models/lib/filesRoot.js, shared by every place that derived it).

Started in a terminal it shows a menu: start, stop, status, version info
as Admin Panel / Version shows it, update to the newest release or change
to any other (checksum verified, backed up first, the old executable
kept), backup to wekan-files/backup/YYYY-MM-DD_HH-MM-SS, run at boot
(systemd, launchd, Task Scheduler, cron, Termux:Boot), log, settings.
Every entry is also a command. start runs the start-wekan.sh loop:
recovery, requested restore, FerretDB, recovery page, WeKan, restart.

Meteor's main.js chdir()s into the bundle, which a VFS cannot be, so the
server role keeps a virtual working directory. Node 26.11.1 breaks
fs.writev/readv while any VFS is mounted; they are rebuilt on fs.write
and fs.read.

single-file.yml builds every target in releases/single-file/targets.json
from its verified bundle (Macs on a Mac, to re-sign), starts it where a
runner exists with browser tests on amd64, and attaches what started.
Release All and Release All Missing call it.

Thanks to xet7 !
2026-10-13 21:08:09 +03:00

105 lines
3.8 KiB
JavaScript

// ============================================================================
// Runtime self-checks (docs/Security/Remediation/WeKan.md §9b)
// ----------------------------------------------------------------------------
// Cheap invariants — the kind an existing WeKan UNIT test asserts — that can be
// checked at runtime WITHOUT a browser and WITHOUT Playwright. Any failure is
// recorded to the Tests stream (Admin Panel → Problems → Tests) via testLog.
//
// WeKan does NOT require Playwright to be installed: browser-only end-to-end
// (Playwright) failures are deliberately NOT detected here — only issues
// detectable without it. Passing checks are not logged (only failures).
//
// Best-effort and never throws into startup. Server-only.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import fs from 'fs';
import path from 'path';
import v8 from 'v8';
const { isFilesRoot } = require('/models/lib/filesRoot');
const { heapHealth, diskHealth, DEFAULT_DISK_BYTES } = require('/models/lib/runtimeHealth');
import { recordFailure } from '/server/lib/testLog';
function filesRoot() {
const base = process.env.WRITABLE_PATH || process.cwd();
return isFilesRoot(base) ? base : path.join(base, 'files');
}
// A trivial round-trip proves the database (FerretDB or MongoDB) is reachable
// and queryable — a failure here would break every WeKan test that touches data.
async function checkDatabaseRoundtrip() {
await Meteor.users.find({}, { limit: 1 }).countAsync();
}
// WRITABLE_PATH/files must be writable (attachments, avatars, backups depend on
// it) — a WeKan startup/upload test asserts this.
async function checkWritablePath() {
const dir = filesRoot();
fs.mkdirSync(dir, { recursive: true });
const probe = path.join(dir, '.wekan-selfcheck-' + process.pid);
fs.writeFileSync(probe, 'ok');
fs.unlinkSync(probe);
}
async function checkHeapHeadroom() {
const stats = v8.getHeapStatistics();
const result = heapHealth(stats.used_heap_size, stats.heap_size_limit);
if (!result.ok) throw new Error(result.detail);
}
async function checkFreeDiskSpace() {
const stats = fs.statfsSync(filesRoot());
const configured = Number(process.env.WEKAN_MIN_FREE_DISK_MB);
const minimum = Number.isFinite(configured) && configured >= 0
? configured * 1024 * 1024 : DEFAULT_DISK_BYTES;
const result = diskHealth(stats.bavail, stats.bsize, minimum);
if (!result.ok) throw new Error(result.detail);
}
const CHECKS = [
{ name: 'database-roundtrip', fn: checkDatabaseRoundtrip },
{ name: 'writable-path', fn: checkWritablePath },
{ name: 'heap-headroom', fn: checkHeapHeadroom },
{ name: 'free-disk-space', fn: checkFreeDiskSpace },
];
// Run all self-checks; record each failure to the Tests stream. Returns a small
// summary. Never throws.
export async function runSelfChecks() {
let failures = 0;
for (const c of CHECKS) {
try {
await c.fn();
} catch (e) {
failures += 1;
try {
recordFailure({
suite: 'unit',
test: 'self-check:' + c.name,
message: (e && e.message) || String(e),
});
} catch (_) { /* logging must never break a self-check */ }
}
}
return { checks: CHECKS.length, failures };
}
Meteor.startup(() => {
// Give the DB/connection a moment, then run once. Best-effort.
Meteor.setTimeout(() => {
runSelfChecks().catch(() => {});
}, 15000);
});
Meteor.methods({
// Admin-only: re-run the runtime self-checks on demand (no Playwright needed).
async runSelfChecks() {
const uid = this.userId;
const user = uid && (await Meteor.users.findOneAsync(uid));
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin only');
}
return runSelfChecks();
},
});