Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
6 changed files with 185 additions and 2 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.17-beta
|
0.3.18-beta
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@
|
||||||
.tbtn:hover{filter:brightness(.94);}
|
.tbtn:hover{filter:brightness(.94);}
|
||||||
.tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);}
|
.tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);}
|
||||||
.tbtn.ghost:hover{background:var(--surface-3); filter:none;}
|
.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) */
|
/* in de menubalk zelf zijn ghost-knoppen transparant (rustiger beeld) */
|
||||||
#toolbar .tbtn.ghost{background:transparent; border:none;}
|
#toolbar .tbtn.ghost{background:transparent; border:none;}
|
||||||
#toolbar .tbtn.ghost:hover{background:var(--surface-2);}
|
#toolbar .tbtn.ghost:hover{background:var(--surface-2);}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="am-h3" id="backupsHead" data-i18n="stBackups"></div>
|
||||||
|
<div class="am-reset" id="backupsBlock">
|
||||||
|
<div id="backupsList"></div>
|
||||||
|
<div class="guestnote" data-i18n="backupsNote"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="am-h3" data-i18n="stSession"></div>
|
<div class="am-h3" data-i18n="stSession"></div>
|
||||||
<div class="am-reset">
|
<div class="am-reset">
|
||||||
<div class="lrow">
|
<div class="lrow">
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* 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. */
|
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(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -139,6 +139,9 @@ const I18N = {
|
||||||
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
|
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
|
||||||
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
|
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
|
||||||
stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie",
|
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",
|
stView:"Weergave", vwTheme:"Thema", vwLight:"Licht", vwDark:"Donker", vwAuto:"Auto",
|
||||||
vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal",
|
vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal",
|
||||||
vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts",
|
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",
|
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
|
||||||
settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…",
|
settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…",
|
||||||
stAccount:"Account", stPassword:"Password", stSession:"Session",
|
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",
|
stView:"Display", vwTheme:"Theme", vwLight:"Light", vwDark:"Dark", vwAuto:"Auto",
|
||||||
vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language",
|
vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language",
|
||||||
vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right",
|
vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right",
|
||||||
|
|
@ -579,8 +585,61 @@ function openSettings(){
|
||||||
(zo blijven taal/contrast/thema ook als gast bereikbaar) */
|
(zo blijven taal/contrast/thema ook als gast bereikbaar) */
|
||||||
document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none";
|
document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none";
|
||||||
switchSettingsTab(currentUser ? "account" : "view");
|
switchSettingsTab(currentUser ? "account" : "view");
|
||||||
|
loadBackups();
|
||||||
settingsWrap.classList.add("open");
|
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", ()=>{
|
document.getElementById("btnUser").addEventListener("click", ()=>{
|
||||||
if(currentUser){ openSettings(); return; }
|
if(currentUser){ openSettings(); return; }
|
||||||
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
|
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
|
||||||
|
|
|
||||||
48
src/api.js
48
src/api.js
|
|
@ -166,7 +166,27 @@ export default async function api(app) {
|
||||||
const n = Number(h);
|
const n = Number(h);
|
||||||
return Number.isInteger(n) && n >= 0 ? n : null;
|
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) => {
|
const writeUserData = async (req, reply, data) => {
|
||||||
|
await snapshotUserData(req.user.id);
|
||||||
const rev = revFromRequest(req);
|
const rev = revFromRequest(req);
|
||||||
const r = rev === null
|
const r = rev === null
|
||||||
? await pool.query(
|
? 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');
|
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
|
||||||
return { ok: true, rev: Number(r.rows[0].data_rev) };
|
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) => {
|
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
const data = req.body ?? {};
|
const data = req.body ?? {};
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
assert.ok(!upd.sql.includes('AND data_rev'));
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue