code-kumpel/brain/sync.js
2026-10-31 10:35:44 +01:00

76 lines
3.7 KiB
JavaScript

/**
* Geräte-Sync (optional). GET/PUT /sync mit Header X-Sync-Code.
* Speichert nur den Hash des Codes. Last-write-wins mit Versionszähler.
* Lehnt Spielstände mit erkennbaren Secrets ab.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const SECRET_RE = /(sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abp]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
const MAX_BYTES = 2 * 1024 * 1024;
function createStore(file) {
const f = file || process.env.SYNC_FILE || path.join(__dirname, 'data', 'sync.json');
let db = {};
try { db = JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { db = {}; }
const persist = () => { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f + '.tmp', JSON.stringify(db)); fs.renameSync(f + '.tmp', f); };
return {
get: (id) => db[id] || null,
put: (id, rec) => { db[id] = rec; persist(); },
file: f
};
}
function idFor(code) {
if (!code || String(code).length < 12) return null;
return crypto.createHash('sha256').update('ck-sync:' + code).digest('hex');
}
/** returns {status, body} */
function handle(store, method, code, body) {
const id = idFor(code);
if (!id) return { status: 401, body: { error: 'sync_code_invalid' } };
if (method === 'GET') {
const rec = store.get(id);
if (!rec) return { status: 404, body: { error: 'no_state' } };
return { status: 200, body: rec };
}
if (method === 'PUT' || method === 'POST') {
if (!body || typeof body.version !== 'number' || typeof body.state !== 'object' || body.state === null) return { status: 400, body: { error: 'bad_request' } };
const raw = JSON.stringify(body.state);
if (raw.length > MAX_BYTES) return { status: 413, body: { error: 'too_large' } };
if (SECRET_RE.test(raw)) return { status: 422, body: { error: 'secret_detected' } };
const cur = store.get(id);
if (cur && body.version <= cur.version) return { status: 409, body: { error: 'stale', version: cur.version, updatedAt: cur.updatedAt } };
const rec = { version: body.version, updatedAt: Number(body.updatedAt) || Date.now(), state: body.state };
store.put(id, rec);
return { status: 200, body: { ok: true, version: rec.version } };
}
return { status: 405, body: { error: 'method' } };
}
/** Async-Variante für db.js-KV (Namespace 'sync'). id: 'user:<uuid>' (Konto) oder Hash des Sync-Codes. */
async function handleAsync(kv, method, id, body) {
const store = { get: (k) => kv.get('sync', k), put: (k, v) => kv.put('sync', k, v) };
if (!id) return { status: 401, body: { error: 'unauthorized' } };
if (method === 'GET') {
const rec = await store.get(id);
if (!rec) return { status: 404, body: { error: 'no_state' } };
return { status: 200, body: rec };
}
if (method === 'PUT' || method === 'POST') {
if (!body || typeof body.version !== 'number' || typeof body.state !== 'object' || body.state === null) return { status: 400, body: { error: 'bad_request' } };
const raw = JSON.stringify(body.state);
if (raw.length > MAX_BYTES) return { status: 413, body: { error: 'too_large' } };
if (SECRET_RE.test(raw)) return { status: 422, body: { error: 'secret_detected' } };
const cur = await store.get(id);
if (cur && body.version <= cur.version) return { status: 409, body: { error: 'stale', version: cur.version, updatedAt: cur.updatedAt } };
const rec = { version: body.version, updatedAt: Number(body.updatedAt) || Date.now(), state: body.state };
await store.put(id, rec);
return { status: 200, body: { ok: true, version: rec.version } };
}
return { status: 405, body: { error: 'method' } };
}
module.exports = { createStore, handle, handleAsync, idFor };