mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-26 16:45:16 +00:00
Default Theme in Appearance & Welcome and the theme in Defaults for New Members were stored separately, so a new member saw one on the sign-in page and the other once the shared defaults applied. Sharing a theme now also sets the Default Theme (when it is a built-in or a published custom theme), changing the Default Theme updates the shared theme, choosing None removes it from the shared set, and unpublishing a custom default drops it from both. The shared version is untouched, so nobody is re-sent defaults.
242 lines
12 KiB
JavaScript
242 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
// Defaults for new members (#5739): an admin shares a snapshot of their own
|
|
// look-and-feel settings; each member receives it once, only over settings
|
|
// they have not changed themselves.
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const md = require('../src/memberDefaults');
|
|
const { checkSetting } = require('../src/serverTemplate');
|
|
const register = require('../src/socketHandlers/memberDefaults');
|
|
|
|
function freshDb() {
|
|
const db = new Database(':memory:');
|
|
db.exec(`
|
|
CREATE TABLE server_settings (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE user_preferences (user_id INTEGER, key TEXT, value TEXT, PRIMARY KEY (user_id, key));
|
|
`);
|
|
return db;
|
|
}
|
|
const pref = (db, uid, key) => db.prepare('SELECT value FROM user_preferences WHERE user_id = ? AND key = ?').get(uid, key)?.value;
|
|
const setPref = (db, uid, key, value) => db.prepare('INSERT OR REPLACE INTO user_preferences (user_id, key, value) VALUES (?, ?, ?)').run(uid, key, value);
|
|
|
|
test('only allowlisted cosmetic settings with valid values are kept', () => {
|
|
const s = md.cleanSettings({
|
|
theme: 'matrix', density: 'compact', zoom: 120, blur_nsfw: false, layout: 'BraidLayout',
|
|
effects: '["crt","matrix"]', interface_icons: 'emoji',
|
|
// Not cosmetic, or not valid: all dropped.
|
|
e2e: 'off', push_to_talk: 'Space', input_device: 'x', notifications: 'all',
|
|
reaction_size: 'huge', zoom2: 1, toggle_style: 'switch ',
|
|
});
|
|
assert.deepEqual(Object.keys(s).sort(), ['blur_nsfw', 'density', 'effects', 'interface_icons', 'layout', 'theme', 'zoom']);
|
|
assert.deepEqual(md.cleanSettings({ theme: 'custom', zoom: 300, layout: '../evil', effects: '["<x>"]', hide_send: 'true' }), {});
|
|
assert.deepEqual(md.cleanSettings({ theme: 'file:Neon.theme.css' }), { theme: 'file:Neon.theme.css' });
|
|
assert.deepEqual(md.cleanSettings({ theme: 'file:../x.css' }), {});
|
|
assert.deepEqual(md.cleanSettings('not an object'), {});
|
|
});
|
|
|
|
test('saving keeps the version, clearing keeps it, applying to everyone raises it', () => {
|
|
const db = freshDb();
|
|
assert.equal(md.saveDefaults(db, { bogus: 1 }), null, 'nothing valid to save');
|
|
md.saveDefaults(db, { density: 'compact' });
|
|
assert.deepEqual(md.readSnapshot(db), { v: 1, s: { density: 'compact' } });
|
|
md.saveDefaults(db, { density: 'spacious', theme: 'nord' });
|
|
assert.equal(md.readSnapshot(db).v, 1, 'a new snapshot alone does not re-send to members who got one');
|
|
md.bumpDefaults(db, 5000);
|
|
assert.equal(md.readSnapshot(db).v, 5000);
|
|
md.bumpDefaults(db, 10);
|
|
assert.equal(md.readSnapshot(db).v, 5001, 'always goes up');
|
|
md.clearDefaults(db);
|
|
assert.deepEqual(md.readSnapshot(db), { v: 5001, s: {} });
|
|
assert.equal(md.bumpDefaults(db), null, 'nothing to apply once cleared');
|
|
});
|
|
|
|
test('a member gets account defaults once, only where they never chose', () => {
|
|
const db = freshDb();
|
|
md.saveDefaults(db, { theme: 'matrix', effects: 'none', density: 'compact' });
|
|
setPref(db, 7, 'effects', '["crt"]'); // their own pick
|
|
|
|
assert.deepEqual(md.applyAccountDefaults(db, 7), { theme: 'matrix' });
|
|
assert.equal(pref(db, 7, 'theme'), 'matrix');
|
|
assert.equal(pref(db, 7, 'effects'), '["crt"]', 'their own effects stay');
|
|
assert.deepEqual(JSON.parse(pref(db, 7, md.RECORD_PREF)), { v: 1, a: { theme: 'matrix' } });
|
|
|
|
// Second load: already received.
|
|
assert.deepEqual(md.applyAccountDefaults(db, 7), {});
|
|
// They change the theme; nothing puts it back.
|
|
setPref(db, 7, 'theme', 'dracula');
|
|
md.saveDefaults(db, { theme: 'nord' });
|
|
assert.deepEqual(md.applyAccountDefaults(db, 7), {});
|
|
assert.equal(pref(db, 7, 'theme'), 'dracula');
|
|
});
|
|
|
|
test('applying to everyone updates untouched settings and leaves changed ones', () => {
|
|
const db = freshDb();
|
|
md.saveDefaults(db, { theme: 'matrix', effects: 'none' });
|
|
md.applyAccountDefaults(db, 1);
|
|
md.applyAccountDefaults(db, 2);
|
|
setPref(db, 2, 'theme', 'halo'); // member 2 picked their own theme
|
|
|
|
md.saveDefaults(db, { theme: 'nord', effects: 'auto' });
|
|
md.bumpDefaults(db, 100);
|
|
assert.deepEqual(md.applyAccountDefaults(db, 1), { theme: 'nord', effects: 'auto' });
|
|
assert.deepEqual(md.applyAccountDefaults(db, 2), { effects: 'auto' });
|
|
assert.equal(pref(db, 2, 'theme'), 'halo');
|
|
// A member who joins later gets the current set.
|
|
assert.deepEqual(md.applyAccountDefaults(db, 3), { theme: 'nord', effects: 'auto' });
|
|
});
|
|
|
|
test('a setting left out of a later set stays on record while untouched', () => {
|
|
const db = freshDb();
|
|
md.saveDefaults(db, { theme: 'matrix' });
|
|
md.applyAccountDefaults(db, 1);
|
|
md.saveDefaults(db, { effects: 'none' });
|
|
md.bumpDefaults(db, 100);
|
|
md.applyAccountDefaults(db, 1);
|
|
assert.deepEqual(JSON.parse(pref(db, 1, md.RECORD_PREF)).a, { theme: 'matrix', effects: 'none' });
|
|
md.saveDefaults(db, { theme: 'nord' });
|
|
md.bumpDefaults(db, 200);
|
|
assert.deepEqual(md.applyAccountDefaults(db, 1), { theme: 'nord' });
|
|
});
|
|
|
|
test('server templates carry the settings without the version', () => {
|
|
const stored = JSON.stringify({ v: 98765, s: { density: 'compact', theme: 'matrix', junk: 1 } });
|
|
assert.deepEqual(JSON.parse(checkSetting('member_defaults', stored)), { v: 0, s: { theme: 'matrix', density: 'compact' } });
|
|
assert.equal(checkSetting('member_defaults', JSON.stringify({ v: 3, s: {} })), '');
|
|
assert.equal(checkSetting('member_defaults', '{oops'), '');
|
|
});
|
|
|
|
function fakeSocket(user, perms = () => false) {
|
|
const handlers = new Map();
|
|
const sent = [];
|
|
const settingChanges = [];
|
|
const socket = {
|
|
user,
|
|
on: (ev, fn) => handlers.set(ev, fn),
|
|
emit: (ev, data) => sent.push([ev, data]),
|
|
};
|
|
const db = freshDb();
|
|
register(socket, {
|
|
db, io: null, userHasPermission: perms,
|
|
settingEffects: {
|
|
emitSettingChanged: (k, v) => settingChanges.push([k, v]),
|
|
auditSettingChange: () => {},
|
|
},
|
|
});
|
|
return { handlers, sent, settingChanges, db };
|
|
}
|
|
|
|
test('only admins or manage_server holders can change the defaults, and errors deny', () => {
|
|
const member = fakeSocket({ id: 2, isAdmin: false });
|
|
member.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
|
|
member.handlers.get('push-member-defaults')();
|
|
member.handlers.get('clear-member-defaults')();
|
|
assert.deepEqual(md.readSnapshot(member.db), { v: 0, s: {} });
|
|
assert.equal(member.settingChanges.length, 0);
|
|
|
|
const broken = fakeSocket({ id: 3, isAdmin: false }, () => { throw new Error('db gone'); });
|
|
broken.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
|
|
assert.deepEqual(md.readSnapshot(broken.db), { v: 0, s: {} }, 'a failed permission check denies');
|
|
|
|
const manager = fakeSocket({ id: 4, isAdmin: false }, (uid, perm) => perm === 'manage_server');
|
|
manager.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
|
|
assert.deepEqual(md.readSnapshot(manager.db).s, { density: 'compact' });
|
|
assert.equal(manager.settingChanges[0][0], 'member_defaults');
|
|
|
|
// Any member may ask for their own defaults.
|
|
manager.handlers.get('apply-member-defaults')();
|
|
assert.deepEqual(manager.sent.at(-1), ['member-defaults-applied', {}]);
|
|
});
|
|
|
|
// One theme choice in two places (#5747): Default Theme in Appearance &
|
|
// Welcome and a shared theme for new members stay the same.
|
|
const defaultTheme = (db) => db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get()?.value;
|
|
|
|
test('a shared theme follows the Default Theme, and None takes it out', () => {
|
|
const db = freshDb();
|
|
assert.equal(md.followDefaultTheme(db, 'nord'), null, 'no shared theme: nothing is added');
|
|
assert.deepEqual(md.readSnapshot(db).s, {});
|
|
|
|
md.saveDefaults(db, { theme: 'matrix', density: 'compact' });
|
|
md.bumpDefaults(db, 4000);
|
|
assert.equal(md.followDefaultTheme(db, 'matrix'), null, 'already the same');
|
|
md.followDefaultTheme(db, 'nord');
|
|
assert.deepEqual(md.readSnapshot(db), { v: 4000, s: { theme: 'nord', density: 'compact' } }, 'the version stays');
|
|
md.followDefaultTheme(db, '');
|
|
assert.deepEqual(md.readSnapshot(db), { v: 4000, s: { density: 'compact' } });
|
|
});
|
|
|
|
test('sharing a theme makes it the Default Theme when it can be one', () => {
|
|
const admin = fakeSocket({ id: 1, isAdmin: true });
|
|
admin.handlers.get('set-member-defaults')({ settings: { theme: 'dracula', density: 'compact' } });
|
|
assert.equal(defaultTheme(admin.db), 'dracula');
|
|
assert.deepEqual(admin.settingChanges.map(([k]) => k), ['member_defaults', 'default_theme']);
|
|
|
|
// Sharing again without a change leaves the Default Theme alone.
|
|
admin.handlers.get('set-member-defaults')({ settings: { theme: 'dracula' } });
|
|
assert.deepEqual(admin.settingChanges.map(([k]) => k), ['member_defaults', 'default_theme', 'member_defaults']);
|
|
|
|
// Without a theme in the set, the Default Theme stays.
|
|
admin.handlers.get('set-member-defaults')({ settings: { density: 'cozy' } });
|
|
assert.equal(defaultTheme(admin.db), 'dracula');
|
|
|
|
// A custom theme that is not published cannot be the Default Theme.
|
|
admin.handlers.get('set-member-defaults')({ settings: { theme: 'file:braid.theme.css' } });
|
|
assert.equal(defaultTheme(admin.db), 'dracula');
|
|
admin.db.prepare('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)').run('published_themes', '["braid.theme.css"]');
|
|
admin.handlers.get('set-member-defaults')({ settings: { theme: 'file:braid.theme.css' } });
|
|
assert.equal(defaultTheme(admin.db), 'file:braid.theme.css');
|
|
});
|
|
|
|
test('changing the Default Theme changes the shared theme too', () => {
|
|
const registerAdmin = require('../src/socketHandlers/admin');
|
|
const db = freshDb();
|
|
const handlers = new Map();
|
|
const changes = [];
|
|
registerAdmin({ user: { id: 1, isAdmin: true }, on: (ev, fn) => handlers.set(ev, fn), emit() {} }, {
|
|
db, io: { except: () => ({ emit() {} }) }, state: { channelUsers: new Map() },
|
|
userHasPermission: () => true, logAudit() {}, automod: { invalidate() {}, settings: () => ({}) },
|
|
settingEffects: {
|
|
emitSettingChanged: (k, v) => changes.push([k, v]),
|
|
auditSettingChange() {}, afterSettingSaved() {}, broadcastLinkPolicy() {},
|
|
},
|
|
});
|
|
const update = handlers.get('update-server-setting');
|
|
|
|
update({ key: 'default_theme', value: 'nord' });
|
|
assert.deepEqual(md.readSnapshot(db).s, {}, 'nothing shared, nothing added');
|
|
|
|
md.saveDefaults(db, { theme: 'matrix', zoom: 110 });
|
|
update({ key: 'default_theme', value: 'tron' });
|
|
assert.deepEqual(md.readSnapshot(db).s, { theme: 'tron', zoom: 110 });
|
|
assert.deepEqual(changes.slice(-2).map(([k]) => k), ['default_theme', 'member_defaults']);
|
|
|
|
update({ key: 'default_theme', value: '' });
|
|
assert.deepEqual(md.readSnapshot(db).s, { zoom: 110 }, 'None (user\'s choice) is no shared theme either');
|
|
|
|
// Unpublishing the custom theme that was the default drops it from both.
|
|
update({ key: 'published_themes', value: '["braid.theme.css"]' });
|
|
md.saveDefaults(db, { theme: 'file:braid.theme.css', zoom: 110 });
|
|
update({ key: 'default_theme', value: 'file:braid.theme.css' });
|
|
update({ key: 'published_themes', value: '[]' });
|
|
assert.equal(defaultTheme(db), '');
|
|
assert.deepEqual(md.readSnapshot(db).s, { zoom: 110 });
|
|
});
|
|
|
|
test('the client applies through the pickers and keeps a per-account device record', () => {
|
|
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/modules/app-member-defaults.js'), 'utf8');
|
|
assert.match(src, /const RECORD_PREFIX = 'haven_member_defaults_';/);
|
|
assert.match(src, /cur === def\.dflt \|\| \(rec && rec\.a\[def\.key\] === cur\)/);
|
|
const app = fs.readFileSync(path.join(__dirname, '..', 'public/js/app.js'), 'utf8');
|
|
assert.ok(app.indexOf('this._setupMemberDefaults()') > app.indexOf('this._setupToolbarIconPicker()'),
|
|
'set up after the pickers it presses');
|
|
// Client keys match the server allowlist.
|
|
const clientKeys = [...src.matchAll(/^\s{4}key: '([a-z_]+)'/gm)].map(m => m[1]).sort();
|
|
assert.deepEqual(clientKeys, Object.keys(md.SETTINGS).sort());
|
|
});
|