mirror of
https://github.com/ancsemi/Haven.git
synced 2026-11-04 21:12:50 +00:00
Admins download the template under Settings > Admin > Server Template, and import one with a preview of what it will create or update. Add missing leaves existing channels, roles and settings alone; Replace layout updates them to match. Neither mode deletes anything. Members, messages, DMs, keys, invite and channel codes, webhook tokens and secret settings are never exported. Settings go through an allowlist, and imports are validated strictly: sizes, names, references, image types and checksums. The channel-name rule, built-in theme list, automod word and escalation checks and image sniffing now each live in one place, since the template code shares them. Co-authored-by: ancsemi <ancsemi@gmail.com>
179 lines
16 KiB
JavaScript
179 lines
16 KiB
JavaScript
'use strict';
|
|
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const zlib = require('node:zlib');
|
|
const Database = require('better-sqlite3');
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'haven-template-'));
|
|
process.env.HAVEN_DATA_DIR = path.join(ROOT, 'source');
|
|
const { initDatabase } = require('../src/database');
|
|
const tpl = require('../src/serverTemplate');
|
|
const SECRET = 'SECRETMARKER';
|
|
const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
const crc = (buf) => { let c = 0xffffffff; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
const chunk = (type, data) => { const len = Buffer.alloc(4), sum = Buffer.alloc(4), body = Buffer.concat([Buffer.from(type), data]); len.writeUInt32BE(data.length); sum.writeUInt32BE(crc(body)); return Buffer.concat([len, body, sum]); };
|
|
const png = (rgb) => { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 2; return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('tEXt', Buffer.from(`Comment\0${SECRET}-exif`)), chunk('IDAT', zlib.deflateSync(Buffer.from([0, ...rgb]))), chunk('IEND', Buffer.alloc(0))]); };
|
|
const dirs = (name) => { const d = path.join(ROOT, name); fs.mkdirSync(path.join(d, 'uploads', 'stickers'), { recursive: true }); return { data: d, uploads: path.join(d, 'uploads'), themes: path.join(d, 'themes') }; };
|
|
const src = dirs('source');
|
|
const source = initDatabase();
|
|
const pristine = path.join(ROOT, 'pristine.db');
|
|
source.exec(`VACUUM INTO '${pristine.replace(/'/g, "''")}'`);
|
|
const freshDb = (name) => { const file = path.join(ROOT, `${name}.db`); fs.copyFileSync(pristine, file); const db = new Database(file); db.pragma('foreign_keys = ON'); return db; };
|
|
const run = (sql, ...args) => source.prepare(sql).run(...args);
|
|
const seed = () => {
|
|
const admin = run('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', 'owner', `$2a$${SECRET}hash`).lastInsertRowid;
|
|
const bob = run('INSERT INTO users (username, password_hash) VALUES (?, ?)', 'bob', `$2a$${SECRET}hash2`).lastInsertRowid;
|
|
try { run('UPDATE users SET email = ? WHERE id = ?', `bob@${SECRET}.example`, bob); } catch {}
|
|
const set = (k, v) => run('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)', k, v);
|
|
set('server_name', 'Test Town'); set('server_title', 'Test Town hangout'); set('welcome_message', 'Hi {user}, read #rules');
|
|
set('channel_sort_mode', 'manual'); set('channel_cat_order', JSON.stringify(['Start', 'Talk', 'Staff'])); set('channel_cat_sort', 'manual');
|
|
set('turn_password', `${SECRET}-turn`); set('turnstile_secret_key', `${SECRET}-turnstile`); set('giphy_api_key', `${SECRET}-giphy-key-1234`);
|
|
set('vanity_code', 'secretvanity'); set('server_code', 'abcd1234'); set('registration_token', `${SECRET}-regtoken`); set('turn_url', 'turn:10.1.2.3:3478');
|
|
set('server_icon', '/uploads/icon.png'); set('permission_thresholds', JSON.stringify({ create_channel: 50 }));
|
|
fs.writeFileSync(path.join(src.uploads, 'icon.png'), png([200, 155, 78]));
|
|
fs.writeFileSync(path.join(src.uploads, 'hero.png'), png([10, 20, 30]));
|
|
const role = (name, level, color, perms, auto = 0) => { const id = run('INSERT INTO roles (name, level, scope, color, auto_assign) VALUES (?, ?, ?, ?, ?)', name, level, 'server', color, auto).lastInsertRowid; perms.forEach((p) => run('INSERT INTO role_permissions (role_id, permission, allowed) VALUES (?, ?, 1)', id, p)); return id; };
|
|
const tester = role('Tester', 10, '#C89B4E', ['upload_files', 'use_voice']);
|
|
const ch = (name, extra = {}) => { const id = run('INSERT INTO channels (name, code, created_by) VALUES (?, ?, ?)', name, Math.random().toString(16).slice(2, 10).padEnd(8, '0'), admin).lastInsertRowid; const cols = Object.keys(extra); if (cols.length) source.prepare(`UPDATE channels SET ${cols.map((c) => `${c} = ?`).join(', ')} WHERE id = ?`).run(...Object.values(extra), id); run('INSERT INTO channel_members (channel_id, user_id) VALUES (?, ?)', id, admin); return id; };
|
|
const welcome = ch('welcome', { category: 'Start', position: 1, read_only: 1, topic: 'Start here', voice_enabled: 0 });
|
|
const rules = ch('rules', { category: 'Start', position: 2, read_only: 1 });
|
|
const general = ch('general', { category: 'Talk', position: 3, show_welcome: 1, slow_mode_interval: 5 });
|
|
const forum = ch('ideas', { category: 'Talk', position: 4, is_forum: 1, forum_tags: JSON.stringify([{ name: 'Big', emoji: '🔶' }, { name: 'Small' }]), forum_layout: JSON.stringify({ view: 'gallery', tile: 12, shape: '4:3', locked: true, at: 1 }) });
|
|
const lounge = ch('lounge', { category: 'Talk', position: 5, text_enabled: 0 });
|
|
const afk = ch('afk', { parent_channel_id: lounge, position: 6 });
|
|
run('UPDATE channels SET afk_sub_code = (SELECT code FROM channels WHERE id = ?), afk_timeout_minutes = 10 WHERE id = ?', afk, lounge);
|
|
const staff = ch('testers', { category: 'Staff', position: 7, is_private: 1, role_gate: JSON.stringify({ mode: 'any', roles: [tester] }) });
|
|
ch('secret-plans', { category: 'Staff', position: 8 });
|
|
const dm = ch('dm-bob', { is_dm: 1 });
|
|
run('INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)', dm, bob, `${SECRET} private dm`);
|
|
run('INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)', general, bob, `${SECRET} user chat`);
|
|
const pin = (channel, author, content) => { const mid = run('INSERT INTO messages (channel_id, user_id, content, is_webhook, webhook_username, webhook_avatar) VALUES (?, NULL, ?, 1, ?, ?)', channel, content, author, '/uploads/icon.png').lastInsertRowid; run('INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)', mid, channel, admin); };
|
|
pin(welcome, 'Guide', '# Welcome\nRead #rules first.\n/uploads/hero.png');
|
|
pin(rules, 'Guide', '1. Be kind <script>alert(1)</script>');
|
|
const userPin = run('INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)', general, bob, `${SECRET} pinned by a member`).lastInsertRowid;
|
|
run('INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)', userPin, general, admin);
|
|
const menu = run('INSERT INTO messages (channel_id, user_id, content) VALUES (?, ?, ?)', general, admin, '🎭 Roles\n🧪 Tester').lastInsertRowid;
|
|
run('INSERT INTO role_menus (message_id, channel_id, created_by, title, data) VALUES (?, ?, ?, ?, ?)', menu, general, admin, 'Roles', JSON.stringify({ roles: [{ roleId: tester, emoji: '🧪' }] }));
|
|
run('INSERT INTO webhooks (channel_id, name, token, avatar_url, created_by, callback_url, callback_secret, subscribed_events) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', general, 'Guide', `${SECRET}token0000`, '/uploads/icon.png', admin, `http://127.0.0.1:9/${SECRET}`, `${SECRET}-cbsecret`, 'member-joined');
|
|
run('INSERT INTO role_channel_access (role_id, channel_id, grant_on_promote, revoke_on_demote) VALUES (?, ?, 1, 1)', tester, staff);
|
|
run('INSERT INTO invite_codes (code, label, created_by) VALUES (?, ?, ?)', `${SECRET}inv`, 'friends', admin);
|
|
run('INSERT OR REPLACE INTO automod_domains (domain, mode, include_subdomains, note, added_by) VALUES (?, ?, ?, ?, ?)', 'example.org', 'deny', 0, 'spam', admin);
|
|
run('INSERT INTO custom_emojis (name, filename, uploaded_by) VALUES (?, ?, ?)', 'wave', 'hero.png', admin);
|
|
try { run('INSERT INTO user_ips (user_id, ip) VALUES (?, ?)', bob, '203.0.113.77'); } catch {}
|
|
return { admin };
|
|
};
|
|
const seeded = seed();
|
|
const exported = () => tpl.exportTemplate(source, { uploadsDir: src.uploads, themesDir: src.themes, excludeChannels: ['secret-plans'], havenVersion: 'test' });
|
|
const strip = (t) => { const o = JSON.parse(JSON.stringify(t)); delete o.exportedAt; const names = Object.fromEntries(Object.entries(o.assets).map(([n, a]) => [n, a.sha256])); const fix = (v) => names[v] || v; o.server.icon = fix(o.server.icon); o.roles = o.roles.map((r) => ({ ...r, icon: fix(r.icon) })); o.webhooks = o.webhooks.map((w) => ({ ...w, avatar: fix(w.avatar) })); o.posts = o.posts.map((p) => ({ ...p, avatar: fix(p.avatar), content: p.content.replace(/\{\{asset:([^}]+)\}\}/g, (_, n) => fix(n)) })); o.emojis = o.emojis.map((e) => ({ ...e, asset: fix(e.asset) })); o.assets = Object.values(names).sort(); return o; };
|
|
test.after(() => { try { source.close(); } catch {} fs.rmSync(ROOT, { recursive: true, force: true }); });
|
|
test('export keeps the layout and leaves out people, messages and secrets', () => {
|
|
const { template } = exported();
|
|
const json = JSON.stringify(template);
|
|
assert.equal(template.format, 'haven-server-template');
|
|
assert.equal(template.version, tpl.VERSION);
|
|
assert.ok(!json.includes(SECRET), 'no secret, member, message or token text');
|
|
for (const bad of ['secretvanity', 'abcd1234', 'turn:10.1.2.3', '203.0.113.77', 'owner', 'bob', 'password', 'dm-bob', 'secret-plans']) assert.ok(!json.includes(bad), `${bad} is not exported`);
|
|
assert.deepEqual(template.channels.map((c) => c.name), ['welcome', 'rules', 'general', 'ideas', 'lounge', 'afk', 'testers']);
|
|
assert.deepEqual(Object.keys(template.server.settings).filter((k) => /turn|giphy|turnstile|registration|vanity|server_code/.test(k)), []);
|
|
const lounge = template.channels.find((c) => c.name === 'lounge');
|
|
assert.equal(lounge.afkChannel, 'afk');
|
|
assert.deepEqual(template.channels.find((c) => c.name === 'testers').roleGate, { mode: 'any', roles: ['tester'] });
|
|
assert.equal(template.posts.length, 2, 'only webhook-authored pinned posts');
|
|
assert.match(template.posts[0].content, /\{\{asset:hero\.png\}\}/);
|
|
assert.deepEqual(template.webhooks, [{ channel: 'general', name: 'Guide', avatar: 'icon.png', events: 'member-joined', canModerate: false, canUseVoice: false }]);
|
|
assert.equal(template.roleMenus[0].roles[0].role, 'tester');
|
|
const icon = Buffer.from(template.assets['icon.png'].data, 'base64');
|
|
assert.ok(!icon.includes(Buffer.from(SECRET)), 'image metadata is stripped');
|
|
});
|
|
test('a fresh server built from the template exports the same template back', () => {
|
|
const { template } = exported();
|
|
const v = tpl.validateTemplate(JSON.parse(JSON.stringify(template)));
|
|
assert.ok(v.template, JSON.stringify(v.errors));
|
|
const dst = dirs('dest');
|
|
const db = freshDb('dest');
|
|
const admin = db.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)').run('newadmin', 'x').lastInsertRowid;
|
|
const report = tpl.applyTemplate(db, v.template, { mode: 'replace', actorId: admin, uploadsDir: dst.uploads, themesDir: dst.themes });
|
|
assert.equal(report.created.channels.length, 7);
|
|
assert.ok(report.created.roles.includes('Tester'));
|
|
assert.equal(report.counts.posts, 2);
|
|
assert.equal(report.counts.roleMenus, 1);
|
|
const again = tpl.exportTemplate(db, { uploadsDir: dst.uploads, themesDir: dst.themes, havenVersion: 'test' }).template;
|
|
const a = strip(template), b = strip(again);
|
|
b.roles = b.roles.filter((r) => a.roles.some((x) => x.name === r.name));
|
|
b.automodDomains = b.automodDomains.filter((d) => a.automodDomains.some((x) => x.domain === d.domain));
|
|
assert.deepEqual(b, a);
|
|
const posts = db.prepare('SELECT content FROM messages WHERE is_webhook = 1 ORDER BY id').all().map((r) => r.content);
|
|
assert.ok(!posts.join('').includes('<script'), 'post text is sanitised');
|
|
assert.match(posts[0], /\/uploads\/hero-[0-9a-f]{10}\.png/);
|
|
assert.ok(fs.readdirSync(dst.uploads).some((f) => /^icon-[0-9a-f]{10}\.png$/.test(f)));
|
|
const token = db.prepare('SELECT token FROM webhooks').get().token;
|
|
assert.match(token, /^[0-9a-f]{64}$/);
|
|
db.close();
|
|
});
|
|
test('merge adds what is missing, leaves existing things alone and never deletes', () => {
|
|
const { template } = exported();
|
|
const v = tpl.validateTemplate(template).template;
|
|
const dst = dirs('merge');
|
|
const db = freshDb('merge');
|
|
const admin = db.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)').run('a', 'x').lastInsertRowid;
|
|
db.prepare("INSERT INTO channels (name, code, topic, category, position) VALUES ('general', 'ffff0001', 'my own topic', 'Mine', 1)").run();
|
|
db.prepare("INSERT INTO channels (name, code, position) VALUES ('keep-me', 'ffff0002', 2)").run();
|
|
db.prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('server_name', 'Mine')").run();
|
|
const report = tpl.applyTemplate(db, v, { mode: 'merge', actorId: admin, uploadsDir: dst.uploads, themesDir: dst.themes });
|
|
const general = db.prepare("SELECT topic, category FROM channels WHERE name = 'general'").get();
|
|
assert.deepEqual(general, { topic: 'my own topic', category: 'Mine' });
|
|
assert.ok(db.prepare("SELECT 1 FROM channels WHERE name = 'keep-me'").get());
|
|
assert.equal(db.prepare("SELECT value FROM server_settings WHERE key = 'server_name'").get().value, 'Mine');
|
|
assert.deepEqual(report.existing.channels, ['general']);
|
|
assert.ok(report.extra.channels.includes('keep-me'));
|
|
assert.equal(report.created.channels.length, 6);
|
|
const second = tpl.applyTemplate(db, v, { mode: 'merge', actorId: admin, uploadsDir: dst.uploads, themesDir: dst.themes });
|
|
assert.equal(second.created.channels.length + second.created.roles.length + second.counts.posts + second.counts.roleMenus + second.created.webhooks.length, 0, 'applying twice changes nothing');
|
|
const replaced = tpl.applyTemplate(db, v, { mode: 'replace', actorId: admin, uploadsDir: dst.uploads, themesDir: dst.themes });
|
|
assert.deepEqual(db.prepare("SELECT topic, category FROM channels WHERE name = 'general'").get(), { topic: '', category: 'Talk' });
|
|
assert.ok(db.prepare("SELECT 1 FROM channels WHERE name = 'keep-me'").get(), 'replace keeps channels outside the template');
|
|
assert.ok(replaced.updated.settings.includes('server_name'));
|
|
db.close();
|
|
});
|
|
test('a dry run changes nothing and writes no files', () => {
|
|
const v = tpl.validateTemplate(exported().template).template;
|
|
const dst = dirs('dry');
|
|
const db = freshDb('dry');
|
|
const before = db.prepare('SELECT (SELECT count(*) FROM channels) + (SELECT count(*) FROM roles) + (SELECT count(*) FROM server_settings) AS n').get().n;
|
|
const report = tpl.applyTemplate(db, v, { mode: 'replace', uploadsDir: dst.uploads, themesDir: dst.themes, dryRun: true });
|
|
assert.equal(report.created.channels.length, 7);
|
|
assert.ok(report.created.files.length > 0);
|
|
assert.equal(db.prepare('SELECT (SELECT count(*) FROM channels) + (SELECT count(*) FROM roles) + (SELECT count(*) FROM server_settings) AS n').get().n, before);
|
|
assert.deepEqual(fs.readdirSync(dst.uploads), ['stickers']);
|
|
db.close();
|
|
});
|
|
test('imports are checked strictly', () => {
|
|
const base = () => JSON.parse(JSON.stringify(exported().template));
|
|
const bad = (mutate, pattern) => { const t = base(); mutate(t); const r = tpl.validateTemplate(t); assert.ok(r.errors, 'rejected'); assert.ok(r.errors.some((e) => pattern.test(e)), r.errors.join('\n')); };
|
|
assert.deepEqual(tpl.validateTemplate({ format: 'something-else' }).errors, ['This is not a Haven server template']);
|
|
bad((t) => { t.version = 99; }, /newer Haven/);
|
|
bad((t) => { t.channels[0].name = '<img src=x onerror=alert(1)>'; }, /channels\[0\]\.name/);
|
|
bad((t) => { t.channels[1].parent = '../../etc'; }, /parent/);
|
|
bad((t) => { t.channels[1].ref = t.channels[0].ref; }, /used twice/);
|
|
bad((t) => { t.assets['../evil.png'] = t.assets['icon.png']; }, /plain file name/);
|
|
bad((t) => { t.assets['fake.png'] = { data: Buffer.from('<svg onload=alert(1)>').toString('base64') }; }, /PNG, JPEG/);
|
|
bad((t) => { t.assets['icon.png'].sha256 = '0'.repeat(64); }, /checksum/);
|
|
bad((t) => { t.roles[0].permissions.push('launch_missiles'); }, /unknown permission/);
|
|
bad((t) => { t.roles[0].name = 'x'.repeat(31); }, /roles\[0\]\.name/);
|
|
bad((t) => { t.server.settings.server_name = 'x'.repeat(33); }, /server_name/);
|
|
bad((t) => { t.posts[0].channel = 'nowhere'; }, /unknown channel/);
|
|
bad((t) => { t.webhooks[0].events = 'everything'; }, /events/);
|
|
bad((t) => { t.channels = Array.from({ length: tpl.LIMITS.channels + 1 }, (_, i) => ({ ref: `c${i}`, name: `c${i}` })); }, /at most/);
|
|
const t = base();
|
|
t.server.settings.turn_password = 'x';
|
|
t.server.settings.registration_token = 'y';
|
|
t.channels[0].topic = '<script>alert(1)</script>Hello';
|
|
const ok = tpl.validateTemplate(t);
|
|
assert.ok(ok.template);
|
|
assert.equal(ok.template.server.settings.turn_password, undefined);
|
|
assert.ok(ok.warnings.some((w) => /turn_password/.test(w)));
|
|
assert.equal(ok.template.channels[0].topic, 'Hello');
|
|
});
|