From 6980d72041aa9940a41357f59b184f1315f6f75d Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 16:23:10 +0200 Subject: [PATCH] v0.3.18-beta: bewaar en herstel eerdere versies van borddata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Elke data-write bewaart eerst de oude data in user_data_versions (hooguit één snapshot per 10 minuten, maximaal 10 versies per gebruiker) - Nieuwe routes: GET /me/data/versions (lijst) en POST /me/data/restore (zet een versie terug; bewaart de huidige data eerst zodat herstel zelf ook omkeerbaar is); beide niet voor leerlingen - Nieuw blok "Reservekopieën" in Instellingen → Account met datum/tijd per versie en een herstelknop met bevestigingsstap (arm-then-confirm) - Vier nieuwe servertests (snapshot vóór write, opruimen op 10, pupil-403, restore bewaart huidige data eerst) --- VERSION | 2 +- public/css/teach.css | 2 ++ public/index.html | 6 ++++ public/js/core.js | 61 ++++++++++++++++++++++++++++++++++- src/api.js | 48 ++++++++++++++++++++++++++++ test/save-system.test.js | 68 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 055fd99..45c52c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.17-beta +0.3.18-beta diff --git a/public/css/teach.css b/public/css/teach.css index 4330598..42dbf12 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -86,6 +86,8 @@ .tbtn:hover{filter:brightness(.94);} .tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);} .tbtn.ghost:hover{background:var(--surface-3); filter:none;} + /* bewapende bevestigingsstap (reservekopie herstellen e.d.) */ + .tbtn.danger{background:var(--red, #d33); color:#fff; border-color:transparent;} /* in de menubalk zelf zijn ghost-knoppen transparant (rustiger beeld) */ #toolbar .tbtn.ghost{background:transparent; border:none;} #toolbar .tbtn.ghost:hover{background:var(--surface-2);} diff --git a/public/index.html b/public/index.html index 7593920..40e21c6 100644 --- a/public/index.html +++ b/public/index.html @@ -156,6 +156,12 @@ +
+
+
+
+
+
diff --git a/public/js/core.js b/public/js/core.js index 2e2480f..a82ea1f 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.17-beta"; +const VERSION = "0.3.18-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -139,6 +139,9 @@ const I18N = { login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…", stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie", + stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.", + backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?", + backupRestored:"Versie hersteld ✓", stView:"Weergave", vwTheme:"Thema", vwLight:"Licht", vwDark:"Donker", vwAuto:"Auto", vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal", vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts", @@ -302,6 +305,9 @@ const I18N = { login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…", stAccount:"Account", stPassword:"Password", stSession:"Session", + stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.", + backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?", + backupRestored:"Version restored ✓", stView:"Display", vwTheme:"Theme", vwLight:"Light", vwDark:"Dark", vwAuto:"Auto", vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language", vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right", @@ -579,8 +585,61 @@ function openSettings(){ (zo blijven taal/contrast/thema ook als gast bereikbaar) */ document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none"; switchSettingsTab(currentUser ? "account" : "view"); + loadBackups(); settingsWrap.classList.add("open"); } +/* reservekopieën (Instellingen → Account): automatisch bewaarde eerdere + versies van de borddata, met een arm-then-confirm herstelknop zoals de + verwijderknop in de boardStrip */ +async function loadBackups(){ + const head = document.getElementById("backupsHead"); + const block = document.getElementById("backupsBlock"); + const list = document.getElementById("backupsList"); + const show = canPersist(); + head.style.display = block.style.display = show ? "" : "none"; + if(!show) return; + list.textContent = ""; + let versions = []; + try{ versions = (await api("/me/data/versions")).versions || []; } + catch(e){ list.textContent = e.message; return; } + if(!versions.length){ + const d = document.createElement("div"); + d.className = "guestnote"; d.textContent = T("backupsEmpty"); + list.appendChild(d); + return; + } + versions.forEach(v=>{ + const row = document.createElement("div"); + row.className = "lrow"; + const when = document.createElement("span"); + when.className = "am-code"; + when.textContent = new Date(v.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"); + const btn = document.createElement("button"); + btn.className = "tbtn ghost"; + btn.type = "button"; + btn.textContent = T("backupRestore"); + let armed = false; + btn.addEventListener("click", async ()=>{ + if(!armed){ armed = true; btn.textContent = T("backupRestoreConfirm"); btn.classList.add("danger"); return; } + try{ + const res = await api("/me/data/restore", { body:{ versionId: v.id } }); + DATA_REV = typeof res.rev === "number" ? res.rev : DATA_REV; + const d = res.data || {}; + THEMES = themesFromData(d); + GENERAL_EXTRA = generalExtraFromData(d); + BS = boardsFromData(d); + document.dispatchEvent(new CustomEvent("wordschange")); + loadCurrentSlot(); + settingsMsg.textContent = T("backupRestored"); + settingsMsg.classList.add("ok"); + loadBackups(); + }catch(e){ settingsMsg.textContent = e.message; } + }); + row.appendChild(when); + row.appendChild(btn); + list.appendChild(row); + }); +} document.getElementById("btnUser").addEventListener("click", ()=>{ if(currentUser){ openSettings(); return; } loginMsg.textContent = ""; loginMsg.classList.remove("ok"); diff --git a/src/api.js b/src/api.js index 57b23b6..fccb047 100644 --- a/src/api.js +++ b/src/api.js @@ -166,7 +166,27 @@ export default async function api(app) { const n = Number(h); return Number.isInteger(n) && n >= 0 ? n : null; }; + // Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar + // hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en + // maximaal 10 versies per gebruiker. + const SNAPSHOT_MIN_AGE = "10 minutes"; + const SNAPSHOT_KEEP = 10; + const snapshotUserData = async (userId) => { + const fresh = (await pool.query( + `SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`, + [userId, SNAPSHOT_MIN_AGE])).rows[0]; + if (fresh) return; + await pool.query( + `INSERT INTO user_data_versions (user_id, data) + SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`, + [userId]); + await pool.query( + `DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN ( + SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 + )`, [userId, SNAPSHOT_KEEP]); + }; const writeUserData = async (req, reply, data) => { + await snapshotUserData(req.user.id); const rev = revFromRequest(req); const r = rev === null ? await pool.query( @@ -178,6 +198,34 @@ export default async function api(app) { if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt'); return { ok: true, rev: Number(r.rows[0].data_rev) }; }; + app.get('/me/data/versions', async (req, reply) => { + need(req, reply); + if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten'); + const rows = (await pool.query( + 'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC', + [req.user.id])).rows; + return { versions: rows.map((v) => ({ id: Number(v.id), createdAt: v.created_at })) }; + }); + app.post('/me/data/restore', async (req, reply) => { + need(req, reply); + if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten'); + const { versionId } = req.body ?? {}; + if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie'); + const v = (await pool.query( + 'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2', + [versionId, req.user.id])).rows[0]; + if (!v) return fail(reply, 404, 'versie onbekend'); + // de huidige data eerst zelf als versie bewaren, zodat een herstel ook + // weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel) + await pool.query( + `INSERT INTO user_data_versions (user_id, data) + SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`, + [req.user.id]); + const r = await pool.query( + 'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev', + [v.data, req.user.id]); + return { data: v.data, rev: Number(r.rows[0].data_rev) }; + }); app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { need(req, reply); const data = req.body ?? {}; diff --git a/test/save-system.test.js b/test/save-system.test.js index d0e4710..871f23a 100644 --- a/test/save-system.test.js +++ b/test/save-system.test.js @@ -76,3 +76,71 @@ test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async assert.ok(!upd.sql.includes('AND data_rev')); await app.close(); }); + +test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => { + const { app, calls } = await makeApp({ dataRev: 3 }); + const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, + headers: { 'x-data-rev': '3' }, payload: { boards: {} } }); + assert.equal(res.statusCode, 200, res.body); + const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions')); + const cleanIdx = calls.findIndex((c) => c.sql.includes('DELETE FROM user_data_versions')); + const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data =')); + assert.ok(snapIdx >= 0 && cleanIdx >= 0); + assert.ok(snapIdx < updIdx, 'snapshot vóór de overschrijving'); + assert.equal(calls[cleanIdx].params[1], 10); + await app.close(); +}); + +test('GET /me/data/versions weigert leerlingen', async () => { + const { app } = await makeApp({ dataRev: 3 }); + // aparte app met pupil-rol + const pupilApp = await (async () => { + const calls = []; + const pool = { async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{ + id: 8, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0, + }] }; + return { rows: [] }; + } }; + const a = Fastify({ trustProxy: 2 }); + await a.register(cookie); + await a.register(rateLimit, { global: false }); + a.decorate('pg', pool); + await a.register(api, { prefix: '/api' }); + await a.ready(); + return a; + })(); + const res = await pupilApp.inject({ method: 'GET', url: '/api/me/data/versions', cookies: sessionCookie }); + assert.equal(res.statusCode, 403); + await pupilApp.close(); + await app.close(); +}); + +test('POST /me/data/restore zet een versie terug en bewaart de huidige eerst', async () => { + const calls = []; + const pool = { async query(sql, params = []) { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{ + id: 7, username: 'docent', role: 'teacher', school_id: 2, class_id: null, data: { boards: { f: 1 } }, data_rev: 6, + }] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT data FROM user_data_versions')) return { rows: [{ data: { boards: { f: 0 } } }] }; + if (sql.startsWith('UPDATE users SET data =')) return { rows: [{ data_rev: 7 }] }; + return { rows: [] }; + } }; + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + const res = await app.inject({ method: 'POST', url: '/api/me/data/restore', cookies: sessionCookie, + payload: { versionId: 12 } }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { data: { boards: { f: 0 } }, rev: 7 }); + const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions')); + const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data =')); + assert.ok(snapIdx >= 0 && snapIdx < updIdx, 'huidige data eerst bewaard'); + await app.close(); +});