Bring the checklist app under version control
The project has lived only as a folder on the Mac Studio and, briefly, as a transfer tarball. This is the first commit: the Express server and its web front end, the Capacitor iOS and Android wrappers, and the release and backup scripts. The repo is the source, not a backup of the machine. Left out on purpose, by the .gitignore files that were already in the tree: node_modules, build output, mobile/www (regenerated from server/public on every build), and server/data, which holds the live checklists and belongs on the NAS rather than in git. Every credential is excluded too — server/.env, the Play upload keystore and its properties file, the App Store Connect key and ios-signing.config. The one secret that was not in an ignored file was the NAS SSH password, hard-coded in build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read it from the login keychain (service checklist-nas, account Christian), falling back to NAS_PASS in the environment, so rotating it is one keychain entry rather than a history rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
// server.js uses crypto.randomBytes only — for session tokens and upload filenames.
|
||||
// Backed by the platform CSPRNG, so tokens are as unguessable as on the real server.
|
||||
function randomBytes(n) {
|
||||
const b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
b.toString = function (enc) {
|
||||
if (enc === 'hex') return Array.from(this).map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
if (enc === 'base64') return btoa(String.fromCharCode.apply(null, Array.from(this)));
|
||||
return Array.from(this).join(',');
|
||||
};
|
||||
return b;
|
||||
}
|
||||
module.exports = { randomBytes, webcrypto: crypto };
|
||||
@@ -0,0 +1,3 @@
|
||||
'use strict';
|
||||
// Configuration comes from the process shim in boot.js, not a .env file.
|
||||
module.exports = { config: () => ({ parsed: {} }) };
|
||||
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
// A minimal Express, just large enough to run server.js in the app with no server.
|
||||
//
|
||||
// Only what server.js actually uses: app.use(fn) / app.use(path, fn), the four verb methods
|
||||
// with variadic middleware (routes are declared as `app.get(path, requireUser, handler)`),
|
||||
// express.json(), express.static(), and the req/res surface the handlers touch. Anything
|
||||
// beyond that is intentionally absent — a silent no-op would hide a real behaviour gap.
|
||||
//
|
||||
// Requests do not arrive over a socket. native-bridge.js calls app.handle() directly with
|
||||
// the method, path and parsed body it intercepted from fetch().
|
||||
|
||||
const vfs = require('../vfs.js');
|
||||
|
||||
function createApp() {
|
||||
const stack = []; // { kind:'use'|'route', method, path, keys, re, handlers }
|
||||
|
||||
// '/api/lists/:id/items/:itemId' → a regex plus the parameter names, so req.params works.
|
||||
function compile(pattern) {
|
||||
const keys = [];
|
||||
const source = String(pattern)
|
||||
.replace(/[.+*?^${}()|[\]\\]/g, '\\$&') // escape regex metacharacters
|
||||
.replace(/\/:([A-Za-z0-9_]+)/g, (_, k) => { keys.push(k); return '/([^/]+)'; });
|
||||
return { keys, re: new RegExp('^' + source + '/?$') };
|
||||
}
|
||||
|
||||
function addRoute(method, path, handlers) {
|
||||
const { keys, re } = compile(path);
|
||||
stack.push({ kind: 'route', method, path, keys, re, handlers });
|
||||
}
|
||||
|
||||
const app = function () {};
|
||||
|
||||
app.use = function (a, b) {
|
||||
if (typeof a === 'function') stack.push({ kind: 'use', mount: '', fn: a });
|
||||
else stack.push({ kind: 'use', mount: String(a).replace(/\/+$/, ''), fn: b });
|
||||
return app;
|
||||
};
|
||||
['get', 'post', 'put', 'patch', 'delete'].forEach((m) => {
|
||||
app[m] = function (path, ...handlers) { addRoute(m.toUpperCase(), path, handlers); return app; };
|
||||
});
|
||||
app.listen = function (_port, cb) { if (typeof cb === 'function') cb(); return { close() {} }; };
|
||||
app.set = function () { return app; };
|
||||
|
||||
/**
|
||||
* Run one request through the stack.
|
||||
* @returns {Promise<{status:number, headers:Object, body:any, isBuffer:boolean}>}
|
||||
*/
|
||||
app.handle = function (method, rawUrl, body, headers) {
|
||||
return new Promise((resolve) => {
|
||||
const qIndex = rawUrl.indexOf('?');
|
||||
const pathname = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
||||
const query = {};
|
||||
if (qIndex !== -1) {
|
||||
new URLSearchParams(rawUrl.slice(qIndex + 1)).forEach((v, k) => { query[k] = v; });
|
||||
}
|
||||
|
||||
const req = {
|
||||
method: String(method).toUpperCase(),
|
||||
url: rawUrl,
|
||||
originalUrl: rawUrl,
|
||||
path: pathname,
|
||||
query,
|
||||
params: {},
|
||||
body: body === undefined ? {} : body,
|
||||
headers: headers || {},
|
||||
get(name) { return (headers || {})[String(name).toLowerCase()]; },
|
||||
};
|
||||
|
||||
let finished = false;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
_headers: {},
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
setHeader(k, v) { this._headers[String(k).toLowerCase()] = v; return this; },
|
||||
getHeader(k) { return this._headers[String(k).toLowerCase()]; },
|
||||
type(t) { return this.setHeader('content-type', t); },
|
||||
json(obj) { this.setHeader('content-type', 'application/json'); this._done(obj, false); },
|
||||
send(data) { this._done(data, data instanceof Uint8Array); },
|
||||
end(data) { this._done(data === undefined ? '' : data, data instanceof Uint8Array); },
|
||||
async sendFile(p) {
|
||||
try {
|
||||
const content = await vfs.raw(p);
|
||||
if (content == null) { this.statusCode = 404; this._done({ error: 'Not found' }, false); return; }
|
||||
this._done(content, content instanceof Uint8Array);
|
||||
} catch (_) { this.statusCode = 500; this._done({ error: 'Read failed' }, false); }
|
||||
},
|
||||
_done(payload, isBuffer) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
resolve({ status: this.statusCode, headers: this._headers, body: payload, isBuffer: !!isBuffer });
|
||||
},
|
||||
};
|
||||
|
||||
// Build the chain for this request: matching app.use middleware, then the first
|
||||
// matching route's own middleware + handler.
|
||||
const chain = [];
|
||||
for (const layer of stack) {
|
||||
if (layer.kind === 'use') {
|
||||
if (!layer.mount || pathname === layer.mount || pathname.startsWith(layer.mount + '/')) {
|
||||
chain.push({ fn: layer.fn, mount: layer.mount });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (layer.method !== req.method) continue;
|
||||
const m = layer.re.exec(pathname);
|
||||
if (!m) continue;
|
||||
layer.keys.forEach((k, i) => { req.params[k] = decodeURIComponent(m[i + 1]); });
|
||||
layer.handlers.forEach((fn) => chain.push({ fn, mount: '' }));
|
||||
break;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const next = (err) => {
|
||||
if (finished) return;
|
||||
if (err) { res.statusCode = 500; res._done({ error: String((err && err.message) || err) }, false); return; }
|
||||
const entry = chain[i++];
|
||||
if (!entry) {
|
||||
// Nothing handled it. Mirrors Express's default 404 so the client's apiFetch sees
|
||||
// the same shape it would from the real server.
|
||||
res.statusCode = 404;
|
||||
res._done({ error: 'Not found' }, false);
|
||||
return;
|
||||
}
|
||||
// A mounted middleware sees the path with its mount point stripped, like Express.
|
||||
const saved = req.url;
|
||||
if (entry.mount) req.url = pathname.slice(entry.mount.length) || '/';
|
||||
let out;
|
||||
try { out = entry.fn(req, res, next); } catch (e) { req.url = saved; next(e); return; }
|
||||
req.url = saved;
|
||||
if (out && typeof out.then === 'function') out.catch(next);
|
||||
};
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// boot.js needs the app instance server.js created, and a signal for when server.js called
|
||||
// listen() — which it does only after loadState() resolves, making it the real readiness
|
||||
// event rather than a guess about timing.
|
||||
let _resolveListened;
|
||||
const _listened = new Promise((r) => { _resolveListened = r; });
|
||||
|
||||
function express() {
|
||||
const app = createApp();
|
||||
const realListen = app.listen;
|
||||
app.listen = function (port, cb) { const out = realListen.call(app, port, cb); _resolveListened(app); return out; };
|
||||
express.__lastApp = app;
|
||||
return app;
|
||||
}
|
||||
express.__listened = _listened;
|
||||
|
||||
// server.js selects a body parser per route, but native-bridge hands us an already-parsed
|
||||
// body — there is no wire format to decode. These stay as pass-through middleware so the
|
||||
// call sites keep working untouched.
|
||||
express.json = function () { return function (req, res, next) { next(); }; };
|
||||
express.urlencoded = function () { return function (req, res, next) { next(); }; };
|
||||
|
||||
// Static mounts exist so /equip-images and /doc-files resolve out of the virtual filesystem.
|
||||
// The app bundle's own assets are served by the WebView, never through here.
|
||||
express.static = function (root) {
|
||||
return async function (req, res, next) {
|
||||
if (req.method !== 'GET') { next(); return; }
|
||||
const rel = decodeURIComponent(String(req.url).split('?')[0]);
|
||||
if (rel.indexOf('..') !== -1) { next(); return; } // never escape the mount root
|
||||
const content = await vfs.raw(root.replace(/\/+$/, '') + rel);
|
||||
if (content == null) { next(); return; }
|
||||
res.send(content);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = express;
|
||||
@@ -0,0 +1,2 @@
|
||||
'use strict';
|
||||
module.exports = require('./fs.js').promises;
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
// Stands in for Node's `fs` when server.js runs inside the app. Only the eleven calls
|
||||
// server.js actually makes are implemented — deliberately, so an unnoticed new fs call
|
||||
// fails loudly here instead of silently doing nothing.
|
||||
const vfs = require('../vfs.js');
|
||||
const promises = {
|
||||
readFile: (p, e) => vfs.readFile(p, e),
|
||||
writeFile: (p, d) => vfs.writeFile(p, d),
|
||||
readdir: (p) => vfs.readdir(p),
|
||||
stat: (p) => vfs.stat(p),
|
||||
unlink: (p) => vfs.unlink(p),
|
||||
rename: (a, b) => vfs.rename(a, b),
|
||||
copyFile: (a, b) => vfs.copyFile(a, b),
|
||||
mkdir: (p, o) => vfs.mkdir(p, o),
|
||||
};
|
||||
module.exports = {
|
||||
existsSync: (p) => vfs.existsSync(p),
|
||||
mkdirSync: (p, o) => vfs.mkdirSync(p, o),
|
||||
statSync: (p) => vfs.statSync(p),
|
||||
promises,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
'use strict';
|
||||
// Scheduled jobs make no sense in a process that only lives while the app is on screen.
|
||||
// The one that matters — list auto-resets — is not cron-driven anyway: server.js recomputes
|
||||
// it inside getState() on every request, so it stays correct simply by being used.
|
||||
module.exports = {
|
||||
validate: () => true,
|
||||
schedule: () => ({ start() {}, stop() {}, destroy() {} }),
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
'use strict';
|
||||
// No SMTP on a phone. Returning a transporter that reports "not sent" keeps the digest and
|
||||
// incident-alert code paths running normally instead of throwing — server.js already handles
|
||||
// an unconfigured mailer gracefully, so this is the shape it expects.
|
||||
module.exports = {
|
||||
createTransport() {
|
||||
return {
|
||||
sendMail: async () => ({ messageId: 'local-mode-no-email', accepted: [], rejected: [] }),
|
||||
verify: async () => false,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
// server.js uses only join() and basename(). POSIX semantics are all that is needed —
|
||||
// the virtual filesystem has no drive letters and no backslashes.
|
||||
function join(...parts) {
|
||||
const joined = parts.filter((p) => p !== '' && p != null).join('/');
|
||||
const absolute = joined.charAt(0) === '/';
|
||||
const out = [];
|
||||
for (const seg of joined.split('/')) {
|
||||
if (seg === '' || seg === '.') continue;
|
||||
if (seg === '..') { if (out.length && out[out.length - 1] !== '..') out.pop(); else if (!absolute) out.push('..'); continue; }
|
||||
out.push(seg);
|
||||
}
|
||||
const res = out.join('/');
|
||||
return absolute ? '/' + res : (res || '.');
|
||||
}
|
||||
function basename(p, ext) {
|
||||
let b = String(p).replace(/\/+$/, '').split('/').pop() || '';
|
||||
if (ext && b.endsWith(ext)) b = b.slice(0, -ext.length);
|
||||
return b;
|
||||
}
|
||||
function dirname(p) {
|
||||
const s = String(p).replace(/\/+$/, '');
|
||||
const i = s.lastIndexOf('/');
|
||||
return i <= 0 ? (i === 0 ? '/' : '.') : s.slice(0, i);
|
||||
}
|
||||
function extname(p) { const b = basename(p); const i = b.lastIndexOf('.'); return i <= 0 ? '' : b.slice(i); }
|
||||
module.exports = { join, basename, dirname, extname, sep: '/', posix: { join, basename, dirname, extname, sep: '/' } };
|
||||
Reference in New Issue
Block a user