import { Router } from 'express'; import type { Request } from 'express'; import SQL from 'sql-template-strings'; import avatar from '../avatar.ts'; import { buildDict, now, shuffle, handleErrorAsync, filterObjectKeys } from '../../src/helpers.ts'; import type { LocaleDescription } from '../../locale/locales.ts'; import allLocales from '../../locale/locales.ts'; import fs from 'fs'; import { caches } from '../../src/cache.ts'; import mailer from '../../src/mailer.ts'; import { profilesSnapshot } from './profile.ts'; import { archiveBan, liftBan } from '../ban.ts'; import markdownit from 'markdown-it'; import { loadCurrentUser } from './user.ts'; import { encodeTime, decodeTime, ulid } from 'ulid'; import Suml from 'suml'; import buildLocaleList from '../../src/buildLocaleList.ts'; import auditLog from '../audit.ts'; import type { UserRow } from './user.ts'; import type { Database } from '../db.ts'; import type { Config } from '../../locale/config.ts'; interface BanProposalRow { id: string; userId: string; bannedBy: string; bannedTerms: string; bannedReason: string; } interface StatRow { id: string; locale: string; users: number; data: string; } interface UserMessageRow { id: string; userId: string; adminId: string; message: string; } const router = Router(); router.get('/admin/list', handleErrorAsync(async (req, res) => { return res.json(await caches.admins.fetch(async () => { const admins = await req.db.all(SQL` SELECT u.username, p.teamName, p.locale, u.id, u.email, u.avatarSource, p.credentials, p.credentialsLevel, p.credentialsName, a.payload FROM users u LEFT JOIN profiles p ON p.userId = u.id LEFT JOIN authenticators a ON u.id = a.userId AND a.type = u.avatarSource WHERE p.teamName IS NOT NULL AND p.teamName != '' AND (a.validUntil IS NULL OR a.validUntil > ${now()}) GROUP BY u.username, p.locale ORDER BY RANDOM() `); const adminsGroupped: Record = buildDict(function* () { yield [global.config.locale, []]; for (const { code, published } of allLocales) { if (code !== global.config.locale && published) { yield [code, []]; } } yield ['', []]; }); for (const admin of admins) { admin.avatar = await avatar(req.db, admin); delete admin.id; delete admin.email; delete admin.payload; if (admin.credentials) { admin.credentials = admin.credentials.split('|'); } if (adminsGroupped[admin.locale] !== undefined) { adminsGroupped[admin.locale].push(admin); } else { adminsGroupped[''].push(admin); } } return adminsGroupped; })); })); router.get('/admin/list/footer', handleErrorAsync(async (req, res) => { return res.json(shuffle(await caches.adminsFooter.fetch(async () => { const fromDb = await req.db.all(SQL` SELECT u.username, p.footerName, p.footerAreas, p.locale FROM users u LEFT JOIN profiles p ON p.userId = u.id WHERE p.locale = ${global.config.locale} AND p.footerName IS NOT NULL AND p.footerName != '' AND p.footerAreas IS NOT NULL AND p.footerAreas != '' `); const fromConfig = global.config.contact.authors || []; return [...fromDb, ...fromConfig]; }))); })); router.get('/admin/users', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const conditions = []; let sql = SQL` SELECT u.id, u.username, u.email, u.roles, u.avatarSource, group_concat(p.locale) AS profiles FROM users u LEFT JOIN profiles p ON p.userId = u.id `; if (typeof req.query.filter === 'string') { conditions.push(SQL`(lower(u.username) LIKE ${`%${req.query.filter.toLowerCase()}%`} OR lower(u.email) LIKE ${`%${req.query.filter.toLowerCase()}%`})`); } if (req.query.localeFilter) { conditions.push(SQL`p.locale=${global.config.locale}`); } if (req.query.adminsFilter) { conditions.push(SQL`u.roles != ''`); } let conditionsSql = SQL``; if (conditions.length) { let i = 0; for (const condition of conditions) { conditionsSql = conditionsSql.append(i++ ? SQL` AND ` : SQL` WHERE `).append(condition); } } sql = sql.append(conditionsSql).append(SQL` GROUP BY u.id ORDER BY u.id DESC LIMIT ${req.query.limit ? parseInt(req.query.limit as string) : 100} OFFSET ${req.query.offset ? parseInt(req.query.offset as string) : 0} `); const countSql = SQL`SELECT COUNT(*) AS c FROM (SELECT u.id FROM users u LEFT JOIN profiles p ON p.userId = u.id`.append(conditionsSql).append(' GROUP BY u.id)'); return res.json({ count: (await req.db.get<{ c: number }>(countSql))!.c, data: (await req.db.all & { profiles: string }>(sql)).map((u) => { return { ...u, profiles: u.profiles ? u.profiles.split(',') : [], }; }), }); })); const fetchStats = async (req: Request): Promise => { const maxId = (await req.db.get<{ maxId: StatRow['id'] | null }>('SELECT MAX(id) AS maxId FROM stats'))!.maxId; if (maxId == null) { return { _: {}, }; } const stats: any = { calculatedAt: decodeTime(maxId) / 1000, }; for (const statsRow of await req.db.all>(SQL`SELECT locale, users, data FROM stats WHERE id = ${maxId}`)) { stats[statsRow.locale] = { users: statsRow.users, ...JSON.parse(statsRow.data), }; } return stats; }; router.get('/admin/stats', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } const stats = await fetchStats(req); for (const locale of Object.keys(stats)) { if (locale === '_' || locale === 'calculatedAt') { continue; } if (!req.isGranted('panel', locale)) { delete stats[locale]; } } return res.json(stats); })); interface Stats { calculatedAt: number; overall: LocaleStats; current: Partial; } interface LocaleStats { users: number; cards: number; pageViews?: number; visitors?: number; online?: number; visitDuration?: number; uptime?: number; responseTime?: number; } router.get('/admin/stats-public', handleErrorAsync(async (req, res) => { const statsAll = await fetchStats(req); const stats: Stats = { calculatedAt: statsAll.calculatedAt, overall: { users: statsAll._.users, cards: 0, pageViews: statsAll._.plausible?.pageviews || 0, visitors: statsAll._.plausible?.visitors || 0, online: statsAll._.plausible?.realTimeVisitors || 0, }, current: {}, }; for (const [locale, localeStats] of Object.entries(statsAll) as any) { if (locale === '_' || locale === 'calculatedAt') { continue; } stats.overall.cards += localeStats.users; if (locale === global.config.locale) { stats.current = { cards: localeStats.users, }; } if (localeStats.plausible) { stats.overall.pageViews += localeStats.plausible.pageviews; stats.overall.visitors += localeStats.plausible.visitors; stats.overall.online += localeStats.plausible.realTimeVisitors; if (locale === global.config.locale) { stats.current.pageViews = localeStats.plausible.pageviews; stats.current.visitors = localeStats.plausible.visitors; stats.current.online = localeStats.plausible.realTimeVisitors; stats.current.visitDuration = localeStats.plausible.visit_duration; } } if (localeStats.heartbeat && locale === global.config.locale) { stats.current.uptime = localeStats.heartbeat.uptime; stats.current.responseTime = localeStats.heartbeat.avgResponseTime; } } return res.json(stats); })); router.get('/admin/stats/users-chart/:locale', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const formatDate = (d: Date) => `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString() .padStart(2, '0')}`; const stats: Record = {}; await req.db.each>( SQL`SELECT id, users FROM stats WHERE locale = ${req.params.locale} ORDER BY id ASC`, (_err, { id, users }) => { const date = formatDate(new Date(decodeTime(id))); stats[date] = users; // overwrite with the latest one for the day }, ); const incrementsChart: Record = {}; let prevUsers = null; for (const [date, users] of Object.entries(stats)) { incrementsChart[date] = prevUsers === null ? users : users - prevUsers; prevUsers = users; } return res.json(incrementsChart); })); type LocalDescriptionWithConfig = LocaleDescription & { config?: Config }; router.get('/admin/all-locales', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } const locales: Record = buildLocaleList(global.config.locale, true); for (const locale in locales) { if (!locales.hasOwnProperty(locale)) { continue; } locales[locale].config = new Suml().parse(fs.readFileSync(`./locale/${locale}/config.suml`).toString()) as Config; } return res.json(locales); })); const normalise = (s: string): string => s.trim().toLowerCase(); const fetchUserByUsername = async (db: Database, username: string) => { return await db.get>(SQL`SELECT id, email FROM users WHERE usernameNorm = ${normalise(username)}`); }; const fetchBanProposals = async (db: Database, userId: string) => { return await db.all(SQL` SELECT p.*, a.username AS bannedByUsername FROM ban_proposals p LEFT JOIN users a ON p.bannedBy = a.id WHERE userId = ${userId} `); }; router.get('/admin/ban-snapshot/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const row = await req.db.get<{ banSnapshot: string }>(SQL` SELECT banSnapshot FROM users WHERE users.id = ${req.params.id} `); return res.json(row ? row.banSnapshot : null); })); router.get('/admin/ban-proposals', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const cutoff = encodeTime(Date.now() - 3 * 31 * 24 * 60 * 60 * 1000, 10) + '0'.repeat(16); return res.json(await req.db.all(SQL` SELECT u.username, group_concat(p.locale) as profiles, count(bp.id) / count(p.locale) as votes FROM ban_proposals bp LEFT JOIN users u ON bp.userId = u.id LEFT JOIN profiles p on u.id = p.userId WHERE bp.id > ${cutoff} AND u.bannedBy IS NULL GROUP BY u.username `)); })); router.get('/admin/ban-proposals/:username', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } return res.json(await fetchBanProposals(req.db, user.id)); })); router.post('/admin/propose-ban/:username', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } if (req.body.reason) { if (!req.body.terms.length) { return res.status(400).json({ error: 'Terms are required' }); } await req.db.get(SQL` DELETE FROM ban_proposals WHERE userId = ${user.id} AND bannedBy = ${req.user!.id} `); await req.db.get(SQL` INSERT INTO ban_proposals (id, userId, bannedBy, bannedTerms, bannedReason) VALUES ( ${ulid()}, ${user.id}, ${req.user!.id}, ${req.body.terms.join(',')}, ${req.body.reason} )`); await auditLog(req, 'mod/proposed_ban', { userId: user.id, terms: req.body.terms, reason: req.body.reason, }); } else { await req.db.get(SQL` DELETE FROM ban_proposals WHERE userId = ${user.id} AND bannedBy = ${req.user!.id} `); await auditLog(req, 'mod/cancelled_ban_proposal', { userId: user.id, }); } return res.json(true); })); router.post('/admin/apply-ban/:username/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } const proposals = await fetchBanProposals(req.db, user.id); if (req.params.id && req.params.id !== '0') { if (!req.isGranted('*') && proposals.length < 2) { return res.status(401).json({ error: 'Unauthorised' }); } const proposal = await req.db.get(SQL`SELECT * FROM ban_proposals WHERE id = ${req.params.id}`); if (!proposal || proposal.userId !== user.id) { return res.status(400).json({ error: 'Invalid ban proposal id' }); } await req.db.get(SQL` UPDATE users SET bannedReason = ${proposal.bannedReason}, bannedTerms = ${proposal.bannedTerms}, bannedBy = ${req.user!.id}, banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))} WHERE id = ${user.id} `); await archiveBan(req.db, user); mailer(user.email, 'ban', { reason: proposal.bannedReason, username: normalise(req.params.username) }); await auditLog(req, 'mod/banned', { userId: user.id, terms: proposal.bannedTerms, reason: proposal.bannedReason, }); } else { await req.db.get(SQL` UPDATE users SET bannedReason = null, bannedBy = ${req.user!.id} WHERE id = ${user.id} `); await auditLog(req, 'mod/unbanned', { userId: user.id, }); await liftBan(req.db, user); } await req.db.get(SQL` UPDATE reports SET isHandled = 1 WHERE userId = ${user.id} `); return res.json(true); })); router.get('/admin/reports', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const cutoff = encodeTime(Date.now() - 3 * 31 * 24 * 60 * 60 * 1000, 10) + '0'.repeat(16); return res.json(await req.db.all(SQL` SELECT reports.id, group_concat(p.locale) as profiles, sus.username AS susUsername, reporter.username AS reporterUsername, reports.comment, reports.isAutomatic, reports.isHandled FROM reports LEFT JOIN users sus ON reports.userId = sus.id LEFT JOIN users reporter ON reports.reporterId = reporter.id LEFT JOIN profiles p on sus.id = p.userId WHERE reports.id > ${cutoff} AND sus.username IS NOT NULL GROUP BY reports.id ORDER BY min(reports.isHandled) ASC, min(reports.isAutomatic) ASC, reports.id ASC `)); })); router.get('/admin/reports/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } return res.json(await req.db.all(SQL` SELECT reports.id, sus.username AS susUsername, reporter.username AS reporterUsername, reports.comment, reports.isAutomatic, reports.isHandled, reports.snapshot FROM reports LEFT JOIN users sus ON reports.userId = sus.id LEFT JOIN users reporter ON reports.reporterId = reporter.id WHERE reports.userId = ${req.params.id} ORDER BY reports.isHandled ASC, reports.id DESC `)); })); router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } await req.db.get(SQL` UPDATE reports SET isHandled = 1 WHERE id=${req.params.id} `); await auditLog(req, 'mod/handled_report', { id: req.params.id, }); return res.json(true); })); const fetchModMessages = async (db: Database, user: Pick) => { return db.all & { adminUsername: UserRow['username'] }>(SQL` SELECT m.id, a.username as adminUsername, m.message FROM user_messages m LEFT JOIN users a ON m.adminId = a.id WHERE m.userId = ${user.id} `); }; router.post('/admin/mod-message/:username', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } if (!req.body.message) { return res.status(400).json({ error: 'Bad request' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } await req.db.get(SQL`INSERT INTO user_messages (id, userId, adminId, message) VALUES ( ${ulid()}, ${user.id}, ${req.user!.id}, ${req.body.message} )`); mailer(user.email, 'modMessage', { message: req.body.message, username: req.params.username, modUsername: req.user!.username, }); await auditLog(req, 'mod/sent_mod_message', { userId: user.id, message: req.body.message, }); return res.json(await fetchModMessages(req.db, user)); })); router.get('/admin/mod-messages/:username', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } return res.json(await fetchModMessages(req.db, user)); })); router.post('/admin/overwrite-sensitive/:username', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } if (req.body.sensitive === undefined || !Array.isArray(req.body.sensitive)) { return res.status(400).json({ error: 'Bad request' }); } const user = await fetchUserByUsername(req.db, req.params.username); if (!user) { return res.status(400).json({ error: 'No such user' }); } await req.db.get(SQL`UPDATE profiles SET sensitive = ${JSON.stringify(req.body.sensitive)} WHERE userId=${user.id} AND locale=${global.config.locale}`); if (req.body.sensitive.length) { mailer(user.email, 'sensitiveApplied', { warnings: req.body.sensitive.join('; '), username: req.params.username, modUsername: req.user!.username, }); } await auditLog(req, 'mod/overwrote_content_warnings', { userId: user.id, warnings: req.body.sensitive, }); return res.json(req.body.sensitive); })); const md = markdownit({ html: true }); router.get('/admin/moderation', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } const dir = `${__dirname}/../../moderation`; return res.json({ susRegexes: fs.readFileSync(`${dir}/sus.txt`).toString('utf-8') .split('\n') .filter((x) => !!x && !x.startsWith('#')), rulesUsers: md.render(fs.readFileSync(`${dir}/rules-users.md`).toString('utf-8')), rulesTerminology: md.render(fs.readFileSync(`${dir}/rules-terminology.md`).toString('utf-8')), rulesSources: md.render(fs.readFileSync(`${dir}/rules-sources.md`).toString('utf-8')), timesheets: md.render(fs.readFileSync(`${dir}/timesheets.md`).toString('utf-8')), }); })); router.post('/admin/set-notification-frequency', handleErrorAsync(async (req, res) => { if (!req.isGranted()) { return res.status(401).json({ error: 'Unauthorised' }); } if (![0, 1, 7].includes(req.body.frequency)) { return res.status(400).json({ error: 'Bad request' }); } await req.db.get(SQL`UPDATE users SET adminNotifications = ${req.body.frequency} WHERE id = ${req.user!.id}`); await auditLog(req, 'team/changed_notification_frequency', { frequency: req.body.frequency, }); return await loadCurrentUser(req, res); })); router.get('/admin/timesheet', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } const ts = (await req.db.get<{ timesheets: string }>(SQL`SELECT timesheets FROM users WHERE id = ${req.user!.id}`))!.timesheets; return res.json(ts ? JSON.parse(ts) : null); })); router.post('/admin/timesheet', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } await req.db.get(SQL`UPDATE users SET timesheets = ${JSON.stringify(req.body.timesheets)} WHERE id = ${req.user!.id}`); await auditLog(req, 'team/updated_timesheets', { timesheets: req.body.timesheets, }); return res.json('OK'); })); router.get('/admin/timesheets', handleErrorAsync(async (req, res) => { if (!req.isGranted('panel')) { return res.status(401).json({ error: 'Unauthorised' }); } const timesheetsByUsername: Record = {}; for (let { username, timesheets } of await req.db.all<{ username: string, timesheets: any }>(SQL`SELECT username, timesheets FROM users WHERE timesheets IS NOT NULL`)) { timesheets = JSON.parse(timesheets); if (!req.isGranted('org')) { delete timesheets.details; } timesheetsByUsername[username] = timesheets; } return res.json(timesheetsByUsername); })); router.get('/admin/audit-log/:username/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('*')) { return res.status(401).json({ error: 'Unauthorised' }); } return res.json(await req.db.all(SQL` SELECT * FROM audit_log WHERE username = ${req.params.username} OR userId = ${req.params.id} ORDER BY id DESC `)); })); router.get('/admin/authenticators/:id', handleErrorAsync(async (req, res) => { if (!req.isGranted('community') && !req.isGranted('*')) { return res.status(401).json({ error: 'Unauthorised' }); } const authenticators = (await req.db.all(SQL` SELECT * FROM authenticators WHERE userId = ${req.params.id} ORDER BY id DESC `)).map((auth) => { delete auth.userId; const payload = JSON.parse(auth.payload); auth.payload = typeof payload === 'string' ? null : filterObjectKeys(payload, ['id', 'email', 'name', 'instance', 'username']); return auth; }); return res.json(authenticators); })); export default router;