// ── Resolve data directory BEFORE loading .env ──────────── const { DATA_DIR, DB_PATH, ENV_PATH, CERTS_DIR, UPLOADS_DIR, DELETED_ATTACHMENTS_DIR, GAMES_ROMS_DIR } = require('./src/paths'); const { purgeDeletedAttachments, resolveDeletedRetentionDays } = require('./src/deletedAttachments'); const { trimUploadsToLimit } = require('./src/uploadsTrim'); // ── Node.js version guard ───────────────────────────────── const nodeMajor = parseInt(process.versions.node.split('.')[0], 10); if (nodeMajor < 22 || nodeMajor > 26) { console.error(`\n Haven requires Node.js 22-26. You have v${process.versions.node}.`); console.error(' If you installed Node.js from nodejs.org, make sure you picked the'); console.error(' LTS version (v22.x), not an older or unsupported version.'); console.error(' LTS download: https://nodejs.org/en/download (choose "LTS")\n'); process.exit(1); } // Bootstrap .env into the data directory if it doesn't exist yet const fs = require('fs'); const path = require('path'); // ── Stale-install guard ─────────────────────────────────── // Updating by unzipping/copying a release over an existing install leaves // behind files that newer versions deleted. That is normally harmless, until // the deleted file is a module that was split into a folder of the same name // (src/socketHandlers.js became src/socketHandlers/ in 2.9.8): require() // resolves the leftover FILE before the directory, so the server silently // runs months-old module code no matter how current every other file is, and // eventually dies somewhere unrelated. A real self-host crashed on boot with // "Cannot read properties of undefined (reading 'activity')" because a // pre-2.9.8 socketHandlers.js was still shadowing the folder, after months // of its socket layer being frozen at the old version while "fully updated". // Catch the pattern generically and say exactly which file to delete. { const srcDir = path.join(__dirname, 'src'); let entries = []; try { entries = fs.readdirSync(srcDir, { withFileTypes: true }); } catch { /* no src folder: the require() calls below fail loudly on their own */ } const dirNames = new Set(entries.filter(e => e.isDirectory()).map(e => e.name)); const stale = entries.filter(e => e.isFile() && e.name.endsWith('.js') && dirNames.has(e.name.slice(0, -3)) && fs.existsSync(path.join(srcDir, e.name.slice(0, -3), 'index.js')) ).map(e => path.join('src', e.name)); if (stale.length > 0) { console.error('\n❌ Stale file(s) from an older Haven install detected:\n'); for (const f of stale) console.error(` ${f}`); console.error('\n Each file above is left over from an old version and hides the'); console.error(' module folder of the same name, so this server would run with'); console.error(' outdated code and fail in confusing ways.'); console.error(' Fix: delete the file(s) listed above (the folders contain the'); console.error(' current code), or update by replacing the whole Haven folder'); console.error(' instead of copying new files over an old install. Your data is'); console.error(` safe: it lives in ${DATA_DIR}, not in the install folder.\n`); process.exit(1); } } // .env holds JWT_SECRET: readable by other accounts on a shared Linux host, // it would let them sign admin tokens. No effect on Windows. function _lockEnvFile() { try { fs.chmodSync(ENV_PATH, 0o600); } catch (err) { console.warn('Could not restrict .env permissions:', err.message); } } if (!fs.existsSync(ENV_PATH)) { const example = path.join(__dirname, '.env.example'); if (fs.existsSync(example)) { fs.copyFileSync(example, ENV_PATH); _lockEnvFile(); console.log(`📄 Created .env in ${DATA_DIR} from template`); } else { // Write a minimal .env so dotenv doesn't fail fs.writeFileSync(ENV_PATH, 'JWT_SECRET=change-me-to-something-random-and-long\n'); } } require('dotenv').config({ path: ENV_PATH }); require('./src/envHash').warnCutValues(ENV_PATH); // Also load the project root .env as an override source. // Docker compose injects it via env_file, but when running directly on the // host the data-directory .env may be stale (created before PUBLIC_URL was // added), so the root .env serves as a fallback for env vars the server // administrator has explicitly set. // // This must be done *after* ENV_PATH so the data-dir .env takes precedence // for server-generated values (JWT_SECRET, VAPID keys). const rootEnv = path.join(__dirname, '.env'); if (fs.existsSync(rootEnv)) { require('dotenv').config({ path: rootEnv, override: false }); require('./src/envHash').warnCutValues(rootEnv); console.log('📄 Loaded project root .env as supplementary source'); } // Send outgoing requests through the host's proxy (https_proxy / http_proxy / // no_proxy) when one is set. Without one this does nothing. require('./src/outboundProxy').install(); const express = require('express'); const { createServer } = require('http'); const { createServer: createHttpsServer } = require('https'); const { Server } = require('socket.io'); const crypto = require('crypto'); const helmet = require('helmet'); const multer = require('multer'); const { sniffImageType } = require('./src/imageMetadata'); const privacyPass = require('./src/privacyPass'); const diskGuard = require('./src/diskGuard'); // (#5505) Refuse uploads that would eat into the reserved disk headroom, so a // full volume can never leave admins unable to delete the files that filled it. const uploadDiskGuard = diskGuard.guardUploads(); console.log(`📂 Data directory: ${DATA_DIR}`); // ── Auto-generate JWT secret (MUST happen before loading auth module) ── if (process.env.JWT_SECRET === 'change-me-to-something-random-and-long' || !process.env.JWT_SECRET) { const generated = crypto.randomBytes(48).toString('base64'); let envContent = fs.readFileSync(ENV_PATH, 'utf-8'); envContent = envContent.replace(/JWT_SECRET=.*/, `JWT_SECRET=${generated}`); fs.writeFileSync(ENV_PATH, envContent, { mode: 0o600 }); _lockEnvFile(); process.env.JWT_SECRET = generated; console.log('🔑 Auto-generated strong JWT_SECRET (saved to .env)'); } // ── Auto-generate VAPID keys for push notifications ────── const webpush = require('web-push'); if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) { const vapidKeys = webpush.generateVAPIDKeys(); let envContent = fs.readFileSync(ENV_PATH, 'utf-8'); envContent += `\nVAPID_PUBLIC_KEY=${vapidKeys.publicKey}\nVAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`; fs.writeFileSync(ENV_PATH, envContent, { mode: 0o600 }); _lockEnvFile(); process.env.VAPID_PUBLIC_KEY = vapidKeys.publicKey; process.env.VAPID_PRIVATE_KEY = vapidKeys.privateKey; console.log('🔔 Auto-generated VAPID keys for push notifications (saved to .env)'); } // Configure web-push with contact email (admin can override via VAPID_EMAIL in .env) const vapidEmail = process.env.VAPID_EMAIL || 'mailto:admin@haven.local'; webpush.setVapidDetails(vapidEmail, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY); const { initDatabase } = require('./src/database'); const { router: authRoutes, authLimiter, verifyToken } = require('./src/auth'); const { setupSocketHandlers, sanitizeText, sanitizeSoundName, sanitizeBorderTransform, toReplyContext } = require('./src/socketHandlers'); const { initFerry, stopFerry } = require('./src/ferry'); const { threadExtras: forumBlogThreadExtras } = require('./src/forumBlog'); const { canAccessVoiceChannel, getAccessibleVoiceChannels } = require('./src/botVoice'); const { CREATOR_NOT_BANNED_SQL } = require('./src/botAccess'); const { cleanWebhookName, cleanWebhookAvatarUrl } = require('./src/webhookFields'); const { validHighScore, isKnownGame } = require('./src/highScores'); const protectedMessages = require('./src/protectedMessages'); const { BotAudioManager, inspectAudioFile, MAX_AUDIO_BYTES } = require('./src/botAudio'); const { startTunnel, stopTunnel, getTunnelStatus, registerProcessCleanup } = require('./src/tunnel'); const { startDdns, getDdnsStatus, triggerDdnsNow } = require('./src/ddns'); const { initFcm, setFcmAdminEnabled } = require('./src/fcm'); const threadNotify = require('./src/threadNotify'); const { takeSilent } = require('./src/silentMessages'); const app = express(); const BOT_AUDIO_DIR = path.join(UPLOADS_DIR, 'bot-audio'); fs.mkdirSync(BOT_AUDIO_DIR, { recursive: true }); let botAudioManager = null; let socketRuntime = null; // Values set up further down this file (the socket server, the database, // bot audio) or reassigned while running. Route modules in src/routes read // them through this object at the moment they use them, so they always // see the current one. const late = { get io() { return io; }, get botAudioManager() { return botAudioManager; }, get socketRuntime() { return socketRuntime; }, }; const UPLOAD_PATH_RE = /\/uploads\/((?!(?:bot-audio|deleted-attachments|stickers)\/)(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+)/g; const UPLOAD_URL_PATH_RE = /\/uploads\/+([-A-Za-z0-9_.~%/\\]+)/gi; const storage = require('./src/storage'); const { auditHttp } = require('./src/httpAudit'); const { isSafeUploadRelPath } = storage; function collectUploadRelPaths(contents) { const paths = new Set(); for (const content of contents) { if (typeof content !== 'string' || !content) continue; UPLOAD_URL_PATH_RE.lastIndex = 0; let match; while ((match = UPLOAD_URL_PATH_RE.exec(content)) !== null) { let decoded; try { decoded = decodeURIComponent(match[1]); } catch { continue; } const parts = []; let escapesRoot = false; const segments = decoded.split(process.platform === 'win32' ? /[\\/]+/ : /\/+/); for (const segment of segments) { if (!segment || segment === '.') continue; if (segment === '..') { if (parts.length === 0) { escapesRoot = true; break; } parts.pop(); } else { parts.push(segment); } } if (escapesRoot) continue; const relPath = parts.join('/'); if (/^(?:bot-audio|deleted-attachments|stickers)\//i.test(relPath)) continue; if (isSafeUploadRelPath(relPath)) paths.add(relPath); } } return paths; } // With ownerId, the files are that person's own (a picture they replaced or // removed), of any scope; without it, only channel uploads can be proven // orphaned. async function relocateUnreferencedUploads(db, relPaths, { ownerId = null } = {}) { const isLive = db.prepare("SELECT 1 FROM media_objects WHERE rel_path = ? AND state = 'live'"); const candidates = new Set(Array.from(relPaths).filter(relPath => isLive.get(relPath))); if (candidates.size === 0) return; // Whose file it is comes first: it is one lookup per file, and most // pictures fail it (someone else's upload, an old file with no owner on // record). Only what is left is looked for in every message. const findOwnership = db.prepare( 'SELECT user_id, scope, created_at FROM upload_ownership WHERE rel_path = ?' ); const latestDmMessage = db.prepare(` SELECT MAX(COALESCE(m.edited_at, m.created_at)) AS referenced_at FROM messages m JOIN channels c ON c.id = m.channel_id WHERE c.is_dm = 1 AND m.user_id = ? `); const findProtectedFilenameReference = db.prepare(` SELECT 1 WHERE EXISTS(SELECT 1 FROM custom_sounds WHERE filename = ?) OR EXISTS(SELECT 1 FROM custom_emojis WHERE filename = ?) OR EXISTS(SELECT 1 FROM stickers WHERE filename = ?) `); for (const relPath of candidates) { const ownership = findOwnership.get(relPath); // Legacy/unattributed files and private/profile uploads cannot be proven // orphaned, so leave them in place. A channel upload is also retained if // its owner later sent an encrypted DM that could contain a reference. const keep = !ownership || (ownerId !== null ? ownership.user_id !== ownerId : ownership.scope !== 'channel') || (() => { const dm = latestDmMessage.get(ownership.user_id)?.referenced_at; return !!dm && dm >= ownership.created_at; })() || !!findProtectedFilenameReference.get(relPath, relPath, relPath); if (keep) candidates.delete(relPath); } if (candidates.size === 0) return; const survivingMessages = db.prepare(` SELECT content, persona_avatar, webhook_avatar FROM messages WHERE content LIKE '%/uploads/%' OR persona_avatar IS NOT NULL OR webhook_avatar IS NOT NULL `).iterate(); for (const message of survivingMessages) { for (const relPath of collectUploadRelPaths([ message.content, message.persona_avatar, message.webhook_avatar ])) { candidates.delete(relPath); } if (candidates.size === 0) return; } const protectedUrlReferences = db.prepare(` SELECT avatar AS reference FROM users WHERE avatar LIKE '%/uploads/%' UNION ALL SELECT border FROM users WHERE border LIKE '%/uploads/%' UNION ALL SELECT avatar FROM user_personas WHERE avatar LIKE '%/uploads/%' UNION ALL SELECT avatar_url FROM webhooks WHERE avatar_url LIKE '%/uploads/%' UNION ALL SELECT icon FROM roles WHERE icon LIKE '%/uploads/%' UNION ALL SELECT value FROM server_settings WHERE value LIKE '%/uploads/%' `).iterate(); for (const row of protectedUrlReferences) { for (const relPath of collectUploadRelPaths([row.reference])) candidates.delete(relPath); if (candidates.size === 0) return; } for (const relPath of candidates) await storage.softDelete(relPath); } // ── Per-member upload accounting (#5521) ───────────────── // Admins could see the total size of uploads/ but never who filled it, so one // person quietly using the server as personal cloud storage was invisible // unless you went and read the directory yourself. DM attachments made that // worse: the file bytes are encrypted client-side and the message that links // them is E2E ciphertext, so nothing the server can read connects a private // upload to the person who made it. Recording the owner at the moment of // upload is the only place that link still exists. function recordUploadOwnership(userId, relPath, bytes, scope = 'channel') { if (!Number.isInteger(userId) || !isSafeUploadRelPath(relPath)) return; try { const { getDb } = require('./src/database'); getDb().prepare( 'INSERT OR REPLACE INTO upload_ownership (rel_path, user_id, bytes, scope) VALUES (?, ?, ?, ?)' ).run(relPath, userId, Number.isFinite(bytes) ? Math.max(0, Math.round(bytes)) : 0, ['channel', 'dm', 'profile'].includes(scope) ? scope : 'channel'); } catch (err) { // Accounting is a reporting nicety; never fail a working upload over it. console.warn('[uploads] ownership record failed:', err.message); } } // The uploader's chosen scope only ever narrows what we already know from the // endpoint, so a client that lies about it can shift its own bytes between the // public and private columns of its own row. It cannot move them onto someone // else, and the total (the number that matters here) is unaffected. function uploadScopeFromRequest(req, fallback = 'channel') { const raw = typeof req.body?.scope === 'string' ? req.body.scope.trim().toLowerCase() : ''; return ['channel', 'dm', 'profile'].includes(raw) ? raw : fallback; } // Total the live uploads per owner. Sizes come from the media index that // src/storage keeps with every move, not from upload_ownership's stored byte // count, so a deleted, purged, or moved-to-deleted-attachments file drops out // on its own. Deleted attachments and temporary bot audio are not live member // storage, and neither is in the index as live. let _uploadUsageCache = null; function getUploadUsage() { if (_uploadUsageCache && Date.now() - _uploadUsageCache.at < 60_000) return _uploadUsageCache.data; const sizes = new Map(); // relPath → bytes try { const { getDb } = require('./src/database'); for (const row of getDb().prepare("SELECT rel_path, bytes FROM media_objects WHERE state = 'live'").iterate()) { sizes.set(row.rel_path, row.bytes); } } catch (err) { console.warn('[uploads] Could not read the media index for the usage report:', err.message); } const byUser = new Map(); // userId → { total, channel, dm, profile, files } let attributedBytes = 0; try { const { getDb } = require('./src/database'); const rows = getDb().prepare('SELECT rel_path, user_id, scope FROM upload_ownership').all(); for (const row of rows) { if (row.user_id === null) continue; const bytes = sizes.get(row.rel_path); if (bytes === undefined) continue; // gone from disk, so stop counting it let entry = byUser.get(row.user_id); if (!entry) { entry = { total: 0, channel: 0, dm: 0, profile: 0, files: 0 }; byUser.set(row.user_id, entry); } entry.total += bytes; entry[['channel', 'dm', 'profile'].includes(row.scope) ? row.scope : 'channel'] += bytes; entry.files++; attributedBytes += bytes; } } catch (err) { // Every file then shows as unattributed, so the report is wrong; say why. console.warn('[uploads] Could not read upload ownership for the usage report:', err.message); } let liveBytes = 0; for (const bytes of sizes.values()) liveBytes += bytes; // Uploads made before this shipped have no owner row, so they land here // rather than being silently spread across members who did not make them. const data = { byUser, liveBytes, attributedBytes, unattributedBytes: Math.max(0, liveBytes - attributedBytes), fileCount: sizes.size }; _uploadUsageCache = { at: Date.now(), data }; return data; } // Trust proxy configuration: whose X-Forwarded-For to believe when reading // the real client IP. src/clientIp.js holds the rule, so HTTP and sockets // agree. // // (unset) default: a proxy on this machine only (inside a container, // also the container network's private ranges). Anything // else ignores the header, so nobody can spoof their IP // past the auth rate limiter or an IP ban. // TRUST_PROXY=192.168.1.20 a reverse proxy on another LAN machine // TRUST_PROXY=1 one hop, wherever it is (Cloudflare's proxy, or any proxy // on another machine) // TRUST_PROXY=2 two hops, etc.; TRUST_PROXY=0 trusts nothing // // Without a trusted proxy every user behind one shares its IP in the auth // rate limiter, so innocent users would hit the limit on their first login. const { trustProxySetting, compileTrust, normalizeIp } = require('./src/clientIp'); app.set('trust proxy', trustProxySetting()); // A proxy Haven does not trust shows up as every visitor having the proxy's // address. Say so once per address, with the setting that fixes it, rather // than leave the owner to puzzle over shared rate limits. { const trusted = compileTrust(trustProxySetting()); const warned = new Set(); app.use((req, res, next) => { if (warned.size < 20 && req.headers['x-forwarded-for']) { const peer = normalizeIp(req.socket?.remoteAddress || ''); if (peer && !warned.has(peer) && !trusted(peer, 0)) { warned.add(peer); console.warn(`[proxy] ${peer} sent X-Forwarded-For, but Haven does not trust it as a proxy, so everyone coming through it shares that address. If ${peer} is your reverse proxy, set TRUST_PROXY=${peer} in .env and restart.`); } } next(); }); } // ── IP ban gate (v3.20.0) ───────────────────────────────── // Run before anything else (parsers, helmet, static) so banned addresses // can't consume server resources. Cached for 30s so we aren't hitting SQLite // on every static asset request from a normal page load. Cache is invalidated // from the moderation socket handlers whenever the table changes. // Entries are split into exact addresses (fast Set lookup, the common case) // and CIDR ranges (linear scan, expected to stay small). Both sides are run // through normalizeIp so a ban written as "1.2.3.4" also stops the socket // path, which sees "::ffff:1.2.3.4" on a dual-stack listener. Before v3.42.0 // those two never compared equal and bans silently only half-applied. const _clientIp = require('./src/clientIp'); let _ipBanCache = { set: new Set(), cidrs: [], expires: 0 }; function _refreshIpBanCache() { try { const { getDb } = require('./src/database'); const rows = getDb().prepare('SELECT ip FROM ip_bans').all(); const set = new Set(), cidrs = []; for (const r of rows) { if (!r.ip) continue; if (r.ip.includes('/')) cidrs.push(r.ip); else set.add(_clientIp.normalizeIp(r.ip)); } _ipBanCache = { set, cidrs, expires: Date.now() + 30000 }; } catch (err) { // Keep the bans already known rather than dropping them all: an empty // list here would let every banned address back in until the next read. // Try again in a few seconds. console.warn('[ip-bans] Could not read the ban list, keeping the last one:', err.message); _ipBanCache = { ..._ipBanCache, expires: Date.now() + 5000 }; } } function invalidateIpBanCache() { _ipBanCache.expires = 0; } function isIpBanned(ip) { if (!ip) return false; if (Date.now() > _ipBanCache.expires) _refreshIpBanCache(); const norm = _clientIp.normalizeIp(ip); if (!norm) return false; // This machine always gets in, so whoever runs the server can lift a ban // that went too far, even one placed before ban-ip refused them. if (norm === '127.0.0.1' || norm === '::1') return false; if (_ipBanCache.set.has(norm)) return true; return _ipBanCache.cidrs.some(c => _clientIp.ipMatches(norm, c)); } app.use((req, res, next) => { if (isIpBanned(req.ip)) { return res.status(403).type('text/plain').send('Your IP has been banned from this server.'); } next(); }); // Expose the invalidator on the app so socket handlers can poke it. app.set('invalidateIpBanCache', invalidateIpBanCache); app.set('isIpBanned', isIpBanned); // ── Helper: verify admin from DB (don't trust JWT claims alone) ───── // JWT isAdmin may be stale if admin was demoted since token was issued. // Upload cap for one user: the server setting, or a higher one from a role // they hold (roles.max_upload_mb). Admins are checked against the setting. function uploadCapMb(user) { const db = require('./src/database').getDb(); const base = parseInt(db.prepare("SELECT value FROM server_settings WHERE key = 'max_upload_mb'").get()?.value, 10) || 25; // The admin too: a server owner who lowers the setting for everyone and // gives their own role a higher cap was held to the setting. try { const row = db.prepare(` SELECT MAX(r.max_upload_mb) AS cap FROM roles r JOIN user_roles ur ON ur.role_id = r.id WHERE ur.user_id = ? AND ur.channel_id IS NULL AND r.max_upload_mb IS NOT NULL `).get(user.id); return Math.max(base, parseInt(row?.cap, 10) || 0); } catch { return base; } } function verifyAdminFromDb(user) { if (!user) return false; try { const { getDb } = require('./src/database'); const row = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(user.id); return !!(row && row.is_admin); } catch { return false; } } // The permission check the rest of Haven uses (roles, the level thresholds // and permissions set on one person), made once the database is open. let _permissions = null; function permissionsModule() { if (!_permissions) _permissions = require('./src/socketHandlers/permissions')(require('./src/database').getDb()); return _permissions; } // For the HTTP routes. It used to look only at roles, so someone given a // permission through the level thresholds or on their own (which the app shows // as granted) was refused here, uploads included, and someone denied one on // their own was still let through by a role. function userHasPermission(userId, permission) { if (!userId) return false; try { const { getDb } = require('./src/database'); const db = getDb(); const isAdmin = db.prepare('SELECT is_admin FROM users WHERE id = ?').get(userId); if (isAdmin && isAdmin.is_admin) return true; // A server-wide setting on the person decides first, allow or deny. The // table is created at startup; if this read fails, the outer catch denies // rather than skipping a personal deny and letting a role allow it. const own = db.prepare(` SELECT allowed FROM user_role_perms WHERE user_id = ? AND permission = ? AND channel_id IS NULL ORDER BY allowed ASC LIMIT 1 `).get(userId, permission); if (own) return own.allowed === 1; if (permissionsModule().userHasPermission(userId, permission)) return true; // These routes aren't tied to one channel, so a role held in any channel // still counts, as it always has here. const row = db.prepare(` SELECT 1 FROM role_permissions rp JOIN roles r ON rp.role_id = r.id JOIN user_roles ur ON r.id = ur.role_id WHERE ur.user_id = ? AND rp.permission = ? AND rp.allowed = 1 LIMIT 1 `).get(userId, permission); return !!row; } catch { return false; } } // ── Referrer-Policy (admin-configurable) ───────────────── // The Referrer-Policy header is sent on every response by the security-headers // middleware below. Admins can change it from Settings → Security; the value is // cached in memory (loaded at boot, refreshed when it changes) so we never read // the DB per request. Default matches the value helmet used to set. // // Two of the eight standard policies are deliberately NOT offered: 'unsafe-url' // (sends the full URL to every site, always) and 'no-referrer-when-downgrade' // (sends the full URL to any cross-origin HTTPS site). Haven puts secrets in // the query string (invite links arrive as ?invite=CODE and deep links as // ?channel=CODE&message=ID), and they are only scrubbed by replaceState once // the socket connects. Under either policy, an externally hosted image in the // channel would carry that invite code to its host in the Referer header on // first paint. The six kept here all stop at the origin cross-origin, which is // enough for the case this setting exists for (CDNs like X/Twitter that reject // a cross-origin referrer on video). Anything not in this list falls back to // the default below, so a value saved before this list was narrowed degrades // safely instead of persisting. // Keep in sync with the validation list in src/socketHandlers/admin.js. const VALID_REFERRER_POLICIES = ['no-referrer', 'origin', 'origin-when-cross-origin', 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin']; // Default is 'same-origin' as of 3.41.0, up from the 'strict-origin-when-cross-origin' // helmet used to set. Sending the origin cross-origin is enough for X's video CDN to // return 403, so Twitter/X embeds showed a thumbnail and a dead play button on every // Haven server out of the box. 'same-origin' sends nothing cross-origin, which fixes // that and shares strictly less than before. Admins who need the old behaviour (a host // that uses the referrer for hotlink protection) can pick it in Settings → Security. const DEFAULT_REFERRER_POLICY = 'same-origin'; let currentReferrerPolicy = DEFAULT_REFERRER_POLICY; // ── Security Headers (helmet) ──────────────────────────── // ── Do we serve TLS ourselves? ─────────────────────────── // Resolved here because the security headers below depend on the answer. On // plain HTTP, telling a browser to upgrade every request to HTTPS breaks the // page rather than protecting it: the CSS and JS are re-requested over https on // a port with no TLS listener, so a remote visitor gets an unstyled page with // dead buttons. It looks perfect to whoever is testing on localhost, which // browsers treat as trustworthy and never upgrade. A Windows install whose SSL // step was skipped (no OpenSSL on PATH) lands in exactly that state without // anyone setting FORCE_HTTP. let sslCert = process.env.SSL_CERT_PATH; let sslKey = process.env.SSL_KEY_PATH; const forceHttp = (process.env.FORCE_HTTP || '').toLowerCase() === 'true'; // If not explicitly configured, use the certs in the data directory, and make // them ourselves when they are missing. The startup scripts used to need an // openssl.exe for this, which Windows does not ship (OpenSSH is not OpenSSL), // so those machines silently fell back to HTTP. if (!sslCert && !sslKey) { const autoCert = path.join(CERTS_DIR, 'cert.pem'); const autoKey = path.join(CERTS_DIR, 'key.pem'); if (!forceHttp && !(fs.existsSync(autoCert) && fs.existsSync(autoKey))) { try { const made = require('./src/selfsignedCert').ensureCerts(CERTS_DIR); console.log(`🔒 Generated a self-signed certificate in ${CERTS_DIR} (${made.names.join(', ')})`); } catch (err) { console.warn('⚠️ Could not generate a self-signed certificate:', err.message); } } if (fs.existsSync(autoCert) && fs.existsSync(autoKey)) { sslCert = autoCert; sslKey = autoKey; } } else { // Resolve relative paths against the data directory if (sslCert && !path.isAbsolute(sslCert)) sslCert = path.resolve(DATA_DIR, sslCert); if (sslKey && !path.isAbsolute(sslKey)) sslKey = path.resolve(DATA_DIR, sslKey); } const useSSL = !!(sslCert && sslKey) && !forceHttp; let tlsIssue = null; app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'", "blob:", "https://www.youtube.com", "https://w.soundcloud.com", "https://challenges.cloudflare.com"], // last host: opt-in Turnstile CAPTCHA on registration styleSrc: ["'self'", "'unsafe-inline'"], // inline styles (fonts are self-hosted, no third-party CDN) imgSrc: ["'self'", "data:", "blob:", "https:", "http:"], // link preview OG images + GIPHY (http: for local/self-hosted services) connectSrc: ["'self'", "ws:", "wss:", "https:"], // Socket.IO + cross-origin health checks mediaSrc: ["'self'", "blob:", "data:", "https:", "http:"], // WebRTC audio + notification sounds + link preview video embeds fontSrc: ["'self'"], // self-hosted fonts only (see /public/fonts) workerSrc: ["'self'", "blob:"], // service worker + Ruffle WebAssembly workers objectSrc: ["'none'"], frameSrc: ["'self'", "https://open.spotify.com", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://w.soundcloud.com", "https://challenges.cloudflare.com"], // Listen Together embeds + game iframes + Turnstile widget baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'self'"], // allow mobile app iframe, block third-party clickjacking ...(useSSL ? {} : { upgradeInsecureRequests: null }), // helmet 8.x auto-appends upgrade-insecure-requests; it breaks every page when Haven is not serving TLS } }, crossOriginEmbedderPolicy: false, // needed for WebRTC crossOriginOpenerPolicy: false, // needed for WebRTC hsts: useSSL ? { maxAge: 31536000, includeSubDomains: false } : false, // force HTTPS for 1 year (only sent when we actually serve it) referrerPolicy: false, // set dynamically from the admin-configurable cache in the middleware below })); // Additional security headers helmet doesn't cover app.use((req, res, next) => { res.setHeader('Permissions-Policy', 'camera=(self), microphone=(self), geolocation=(), payment=()'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', currentReferrerPolicy); // admin-configurable (Settings → Security) next(); }); // Disable Express version disclosure app.disable('x-powered-by'); // ── Body Parsing with size limits ──────────────────────── // Global limit bumped to 128kb so legit large-but-bounded payloads like the // per-user saved server list (PUT /api/auth/user-servers, ~40kb at 100+ // servers) aren't rejected by the global parser before per-route parsers // can apply their own limits. Individual routes still set tighter limits // where appropriate. (#5347 v3.15.7) app.use(express.json({ limit: '128kb' })); app.use(express.urlencoded({ extended: false, limit: '128kb' })); // ── Self-hosted fonts (long-lived cache) ───────────────── // Fonts never change for a given filename, so let clients cache them for a // year and skip revalidation. A ?v= bump in style.css busts the cache when a // file is ever replaced. Mounted before the general /public handler so these // win over its always-revalidate (maxAge:0) policy. app.use('/fonts', express.static(path.join(__dirname, 'public', 'fonts'), { dotfiles: 'deny', maxAge: '1y', immutable: true, })); // ── Flash player (Ruffle) ──────────────────────────────── // Served from the npm package pinned in package.json. It used to come from // unpkg, which meant whatever version was newest that day, fetched from a // third party by every player, and a CSP that let the games pages run any // script published to npm. The core scripts and .wasm files carry a content // hash in their names, so those can be cached for good. let RUFFLE_DIR = null; try { RUFFLE_DIR = path.dirname(require.resolve('@ruffle-rs/ruffle/package.json')); } catch { console.warn('Flash games are unavailable: the Flash player package is missing. Run npm install in the Haven folder.'); } if (RUFFLE_DIR) { app.use('/games/ruffle', express.static(RUFFLE_DIR, { dotfiles: 'deny', maxAge: 0, setHeaders: (res, file) => { if (/(^|\.)[0-9a-f]{20}\.(js|wasm)$/.test(path.basename(file))) { res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } }, })); } // Flash games downloaded from Activities, kept in the data folder. Games // installed before that sat in public/games/roms, which the static files // below still serve. app.use('/games/roms', express.static(GAMES_ROMS_DIR, { dotfiles: 'deny', maxAge: 0 })); // ── Static files with caching ──────────────────────────── app.use(express.static(path.join(__dirname, 'public'), { dotfiles: 'deny', // block .env, .git, etc. etag: true, // ETag for conditional requests lastModified: true, // Last-Modified header maxAge: 0, // always revalidate (prevents stale JS/CSS after deploys) })); // ── Serve uploads from storage ────────────────────────── // The internal folders are refused first; see src/storage/serve.js. const { blockInternalUploads, serveUploads } = require('./src/storage/serve'); app.use('/uploads', blockInternalUploads); app.use('/uploads', serveUploads(storage)); // ── Plugin & Theme file serving ───────────────────────── const PLUGINS_DIR = path.join(__dirname, 'plugins'); const THEMES_DIR = path.join(__dirname, 'themes'); const { compatibleThemeFiles, createThemeFileMiddleware, readThemeMetadataSnapshot, validatedThemeDefault, } = require('./src/themeMetadata'); if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true }); if (!fs.existsSync(THEMES_DIR)) fs.mkdirSync(THEMES_DIR, { recursive: true }); // ── Plugin & theme updater ───────────────────────────── // File checks and replacement live in the helper; HTTP authorization stays // here alongside the other admin endpoints. const { createExtensionUpdater } = require('./src/extensionUpdates'); const extensionUpdater = createExtensionUpdater({ dirs: { plugin: PLUGINS_DIR, theme: THEMES_DIR }, stateDir: path.join(DATA_DIR, 'extension-updates'), havenVersion: require('./package.json').version, }); app.use('/plugins', express.static(PLUGINS_DIR, { dotfiles: 'deny', maxAge: 0 })); app.use('/themes', createThemeFileMiddleware(THEMES_DIR)); app.use('/themes', express.static(THEMES_DIR, { dotfiles: 'deny', maxAge: 0 })); // API: list available plugins (*.plugin.js files) app.get('/api/plugins', (req, res) => { try { const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.plugin.js')); const plugins = files.map(f => { // Try to read metadata from the first comment block const content = fs.readFileSync(path.join(PLUGINS_DIR, f), 'utf8'); const meta = {}; const metaMatch = content.match(/\/\*\*[\s\S]*?\*\//); if (metaMatch) { const block = metaMatch[0]; const nameM = block.match(/@name\s+(.+)/); const descM = block.match(/@description\s+(.+)/); const authM = block.match(/@author\s+(.+)/); const verM = block.match(/@version\s+(.+)/); if (nameM) meta.name = nameM[1].trim(); if (descM) meta.description = descM[1].trim(); if (authM) meta.author = authM[1].trim(); if (verM) meta.version = verM[1].trim(); } return { file: f, ...meta }; }); res.json(plugins); } catch { res.json([]); } }); // API: list available themes (*.theme.css files) app.get('/api/themes', (req, res) => { try { let published = []; try { const row = db.prepare("SELECT value FROM server_settings WHERE key = 'published_themes'").get(); if (row) { const stored = JSON.parse(row.value); if (Array.isArray(stored)) published = stored; } } catch { /* DB not ready yet or parse error, default to empty */ } const themes = readThemeMetadataSnapshot(THEMES_DIR) .map(theme => ({ ...theme, published: theme.compatible && published.includes(theme.file) })); res.json(themes); } catch (err) { console.error('Failed to list themes:', err.message); res.status(500).json({ error: 'Failed to list themes' }); } }); // ── File uploads (DB-configurable limit, avatar max 5 MB) ── const uploadDir = UPLOADS_DIR; // Every upload is written into unprocessed/ first and only reaches uploads/ // once its location data is gone (src/privacyPass.js). const uploadStorage = privacyPass.diskStorage({ destination: uploadDir, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`); } }); // Uploads stop at the caller's own cap while they stream. The ceiling used to // be a nominal 100 GB with the real cap checked only once the whole file was // on disk, so anyone signed in could fill the disk with one request (the // avatar route needs no permission at all). Each route still checks its own, // often smaller, limit afterwards. Multer removes a file cut off this way. function uploadCapMbFor(req) { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; const mb = user ? uploadCapMb(user) : 25; return Number.isFinite(mb) && mb > 0 ? mb : 25; } // A multer wrapper that cuts the upload off at the caller's cap and, when it // does, reports it the way the routes do ("File too large (max N MB)"). function cappedUpload(options) { return { single: (field) => (req, res, next) => { const capMb = uploadCapMbFor(req); // Text fields are held in memory, and busboy allows any number of them // by default; a handful of short ones is all any upload form sends. multer({ ...options, limits: { fileSize: capMb * 1024 * 1024 + 1, fields: 20, fieldSize: 16 * 1024 } }).single(field)(req, res, (err) => { if (err && err.code === 'LIMIT_FILE_SIZE') err.message = `File too large (max ${capMb} MB)`; next(err); }); } }; } const imageOnlyFilter = (req, file, cb) => { if (/^image\/(jpeg|png|gif|webp)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only images allowed (jpg, png, gif, webp)')); }; // Image-only upload const upload = cappedUpload({ storage: uploadStorage, fileFilter: imageOnlyFilter }); // General file upload: no MIME restrictions; safety enforced via // Content-Disposition: attachment on non-image downloads (see /uploads handler) const fileUpload = cappedUpload({ storage: uploadStorage }); const botAudioUpload = multer({ storage: multer.diskStorage({ destination: BOT_AUDIO_DIR, filename: (req, file, cb) => { const name = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}.upload`; req.botAudioTempPath = path.join(BOT_AUDIO_DIR, name); cb(null, name); } }), limits: { fileSize: MAX_AUDIO_BYTES, files: 1, fields: 1, parts: 3, fieldSize: 64, fieldNestingDepth: 0, headerPairs: 20 } }); // ── API routes ──────────────────────────────────────────── // authLimiter is applied per-route inside auth.js for credential endpoints // (login, register, TOTP, password change). Non-credential routes like // /validate and /user-servers are intentionally left unlimitted here so // 50+ concurrent users joining a stream event don't trip the limiter. (#5323) app.use('/api/auth', authRoutes); // ── Rich presence: account linking (Steam / Spotify) ───── // Mounted here, ahead of static + SPA handling, so /connect/* is never // swallowed by a catch-all. The activity engine is built later inside // setupSocketHandlers, hence the getter (see activityRef below). const activityRef = { engine: null }; const { createConnectRoutes, baseUrl } = require('./src/connectRoutes'); app.use('/connect', createConnectRoutes(() => activityRef.engine)); // ── Plugin & theme update endpoints (src/routes/extensionUpdates.js) ── require('./src/routes/extensionUpdates')({ app, verifyToken, verifyAdminFromDb, extensionUpdater, late }); // ── Push notification VAPID public key endpoint ────────── app.get('/api/push/vapid-key', (req, res) => { res.json({ publicKey: process.env.VAPID_PUBLIC_KEY }); }); // ── Push notification subscription endpoints ───────────── app.post('/api/push/subscribe', express.json(), async (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const { endpoint, keys } = req.body || {}; if (typeof endpoint !== 'string' || !endpoint || endpoint.length > 2048 || typeof keys?.p256dh !== 'string' || !keys.p256dh || keys.p256dh.length > 512 || typeof keys?.auth !== 'string' || !keys.auth || keys.auth.length > 512) return res.status(400).json({ error: 'Invalid subscription object' }); // Same rule as the socket path: the server posts to this address for every // notification, so it must be a public HTTPS push service, never an address // on the server's own network, unless HAVEN_ALLOW_PRIVATE_CALLBACKS allows it. try { if (new URL(endpoint).protocol !== 'https:') throw new Error('not https'); await require('./src/webhookCallback').resolveCallbackDestination(endpoint, { allowPrivateCallbacks: process.env.HAVEN_ALLOW_PRIVATE_CALLBACKS === 'true' }); } catch (err) { console.warn(`Push subscription refused: ${err.message}`); return res.status(400).json({ error: 'Invalid subscription object' }); } try { const { getDb } = require('./src/database'); const db = getDb(); // An endpoint identifies one browser/device, and only one account can be // signed into it at a time. The table is UNIQUE(user_id, endpoint), so // signing in as someone else used to leave the previous account's row // behind pointing at the same device. Fan-out only skips subscriptions // whose user_id matches the sender, so that stale row kept getting pushed // and the sender received their own messages on their own phone. Claim the // endpoint for this user. db.transaction(() => { db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id != ?').run(endpoint, user.id); db.prepare(` INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, endpoint) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth `).run(user.id, endpoint, keys.p256dh, keys.auth); // Ten devices per person is plenty; the oldest go first. Without a cap // one account could register endless endpoints for the push queue. db.prepare(` DELETE FROM push_subscriptions WHERE user_id = ? AND id NOT IN ( SELECT id FROM push_subscriptions WHERE user_id = ? ORDER BY id DESC LIMIT 10) `).run(user.id, user.id); })(); res.json({ ok: true }); } catch (err) { console.error('[push/subscribe]', err); res.status(500).json({ error: 'Internal server error' }); } }); app.delete('/api/push/subscribe', express.json(), (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const { endpoint } = req.body || {}; if (!endpoint) return res.status(400).json({ error: 'Missing endpoint' }); try { const { getDb } = require('./src/database'); getDb().prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?') .run(user.id, endpoint); res.json({ ok: true }); } catch (err) { console.error('[push/unsubscribe]', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── Per-user channel notification prefs ────────────────── // Mirrors the localStorage `haven_muted_channels` set to the database so // sendPushNotifications can filter out muted recipients before they hit // FCM/web-push (#5399 follow-up: mobile users were getting pushes for // every message regardless of channel mute state because the prefs only // ever lived client-side). app.get('/api/user/channel-prefs', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); try { const { getDb } = require('./src/database'); const rows = getDb().prepare( 'SELECT channel_code FROM user_channel_prefs WHERE user_id = ? AND muted = 1' ).all(user.id); res.json({ muted: rows.map(r => r.channel_code) }); } catch (err) { console.error('[user/channel-prefs GET]', err); res.status(500).json({ error: 'Internal server error' }); } }); app.post('/api/user/channel-prefs/mute', express.json({ limit: '4kb' }), (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const { code, muted } = req.body || {}; if (typeof code !== 'string' || !code.length || code.length > 64) return res.status(400).json({ error: 'Invalid code' }); try { const { getDb } = require('./src/database'); getDb().prepare(` INSERT INTO user_channel_prefs (user_id, channel_code, muted, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id, channel_code) DO UPDATE SET muted = excluded.muted, updated_at = CURRENT_TIMESTAMP `).run(user.id, code, muted ? 1 : 0); res.json({ ok: true }); } catch (err) { console.error('[user/channel-prefs POST]', err); res.status(500).json({ error: 'Internal server error' }); } }); // Bulk replace: used by the client on first sync to push the entire // localStorage set up at once (or to converge after offline edits). app.put('/api/user/channel-prefs/muted', express.json({ limit: '16kb' }), (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const codes = Array.isArray(req.body?.codes) ? req.body.codes : null; if (!codes || codes.length > 500) return res.status(400).json({ error: 'codes array required (max 500)' }); // Filter to plausible channel codes only: strings, 1..64 chars const clean = codes.filter(c => typeof c === 'string' && c.length > 0 && c.length <= 64); try { const { getDb } = require('./src/database'); const db = getDb(); const tx = db.transaction((uid, list) => { db.prepare('DELETE FROM user_channel_prefs WHERE user_id = ? AND muted = 1').run(uid); const ins = db.prepare(` INSERT INTO user_channel_prefs (user_id, channel_code, muted, updated_at) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(user_id, channel_code) DO UPDATE SET muted = 1, updated_at = CURRENT_TIMESTAMP `); for (const c of list) ins.run(uid, c); }); tx(user.id, clean); res.json({ ok: true, count: clean.length }); } catch (err) { console.error('[user/channel-prefs PUT]', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── ICE servers endpoint (STUN + optional TURN) ────────── app.get('/api/ice-servers', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); // TURN credentials relay traffic through the admin's server; a banned // account gets none. try { if (require('./src/database').getDb().prepare('SELECT 1 FROM bans WHERE user_id = ?').get(user.id)) { return res.status(403).json({ error: 'Forbidden' }); } } catch (err) { // Fail closed: without the ban check a banned account would get TURN credentials. console.error('[ice-servers] Ban check failed:', err.message); return res.status(500).json({ error: 'Internal server error' }); } // Admin-configured STUN/TURN (#5399) live in server_settings and take // precedence over env vars, which in turn override the built-in pool. // Admins can now point at their own servers from Settings → Voice & // Connectivity without touching env vars or redeploying. let dbSettings = {}; try { const { getDb } = require('./src/database'); const rows = getDb().prepare( "SELECT key, value FROM server_settings WHERE key IN ('stun_urls','turn_url','turn_username','turn_password','voice_force_relay','voice_ice_disabled')" ).all(); rows.forEach(r => { dbSettings[r.key] = r.value; }); } catch (err) { // Falls back to env/defaults, which ignores the admin's voice settings. console.warn('[ice-servers] Could not read voice settings, using env/defaults:', err.message); } // An explicit admin disable takes precedence over env and built-in servers. // Empty iceServers tells WebRTC to use direct host candidates only. if (dbSettings.voice_ice_disabled === 'true') return res.json({ iceServers: [] }); // STUN precedence: admin setting → STUN_URLS env → built-in defaults. // 3.20.2 (#5399): old defaults (stun.stunprotocol.org + stun.nextcloud.com) // both went offline simultaneously. Mirrors the voice.js client default // pool so any Haven server that hadn't customised STUN would have // returned dead endpoints to its clients here too. const adminStun = (dbSettings.stun_urls || '').trim(); const stunUrls = adminStun ? adminStun.split(/[\n,]/).map(u => u.trim()).filter(Boolean) : process.env.STUN_URLS ? process.env.STUN_URLS.split(',').map(u => u.trim()).filter(Boolean) : [ 'stun:stun.cloudflare.com:3478', 'stun:stun.relay.metered.ca:80', 'stun:global.stun.twilio.com:3478', ]; const iceServers = stunUrls.map(urls => ({ urls })); // TURN precedence: admin setting (static creds) → env (supports HMAC secret). const adminTurn = (dbSettings.turn_url || '').trim(); if (adminTurn) { const u = (dbSettings.turn_username || '').trim(); const p = (dbSettings.turn_password || '').trim(); if (u && p) iceServers.push({ urls: adminTurn, username: u, credential: p }); else iceServers.push({ urls: adminTurn }); } else { const turnUrl = process.env.TURN_URL; if (turnUrl) { const turnSecret = process.env.TURN_SECRET; const turnUser = process.env.TURN_USERNAME; const turnPass = process.env.TURN_PASSWORD; if (turnSecret) { // Time-limited TURN credentials (coturn --use-auth-secret / REST API) const ttl = 24 * 3600; // 24 hours const expiry = Math.floor(Date.now() / 1000) + ttl; const username = `${expiry}:${user.username}`; const hmac = crypto.createHmac('sha1', turnSecret).update(username).digest('base64'); iceServers.push({ urls: turnUrl, username, credential: hmac }); } else if (turnUser && turnPass) { // Static TURN credentials iceServers.push({ urls: turnUrl, username: turnUser, credential: turnPass }); } else { // TURN URL with no auth (uncommon but possible) iceServers.push({ urls: turnUrl }); } } } // Chrome logs "Using five or more STUN/TURN servers slows down discovery" // and genuinely gathers candidates more slowly past that point. A TURN relay // on top of the four built-in STUN defaults lands on exactly five, which is // what dragged out reconnection after a socket flap in #5444 (peers stuck on // ice=checking). Cap the list at four, dropping STUN entries first so the // TURN relay (the one that actually traverses strict NAT) always survives. const MAX_ICE_SERVERS = 4; if (iceServers.length > MAX_ICE_SERVERS) { const isTurn = (s) => /turns?:/i.test(String(s.urls)); const turns = iceServers.filter(isTurn); const stuns = iceServers.filter(s => !isTurn(s)); const keepStun = Math.max(0, MAX_ICE_SERVERS - turns.length); const trimmed = [...stuns.slice(0, keepStun), ...turns]; iceServers.length = 0; iceServers.push(...trimmed); } // ── Relay-only mode (v3.42.0) ─────────────────────────── // Haven voice is a peer-to-peer WebRTC mesh, so in the default configuration // every participant in a call learns every other participant's public IP // from the ICE candidate exchange. No click, no prompt, nothing the user // can see. Sitting idle in a voice channel is enough to collect addresses // from anyone who joins. // // iceTransportPolicy 'relay' makes the browser discard host and // server-reflexive candidates entirely, so peers only ever see the TURN // server's address. That costs bandwidth (all media flows through TURN) and // hard-requires a working TURN server, which is why the settings handler // refuses to turn this on until turn_url is set. Belt-and-braces here too: // if TURN somehow vanished since the toggle was flipped, serve normal ICE // rather than handing clients a config that cannot connect at all. const wantsRelay = dbSettings.voice_force_relay === 'true'; const hasTurn = iceServers.some(s => /^turns?:/i.test(String(s.urls))); if (wantsRelay && hasTurn) { return res.json({ iceServers, iceTransportPolicy: 'relay' }); } if (wantsRelay && !hasTurn) { console.warn('⚠️ voice_force_relay is on but no TURN server is configured; serving normal ICE so voice keeps working'); } res.json({ iceServers }); }); // ── Profile pictures, borders and personas: src/routes/profile.js ── // A profile picture someone replaced or removed goes to deleted-attachments, // if it was their own upload and nothing else still shows it. const releaseOwnUploads = (urls, ownerId) => relocateUnreferencedUploads( require('./src/database').getDb(), collectUploadRelPaths(urls), { ownerId }); require('./src/routes/profile')({ uploadDiskGuard, app, recordUploadOwnership, releaseOwnUploads, verifyAdminFromDb, userHasPermission, upload, uploadLimiter }); // ── Serve pages ────────────────────────────────────────── // ── Connection address (any signed-in user) ────────────── // The status bar used to show window.location.origin, which for the person // running the server is "localhost:3000", useless to share and pointless to // hide or copy. Resolve the address someone else could actually connect on: // an active tunnel wins, otherwise the same PUBLIC_URL / X-Forwarded-Host / // Host resolution the OAuth callbacks already rely on. // // Auth-gated on purpose. This is not in /api/public-config because that is // unauthenticated, and an anonymous caller reaching the box on its LAN // address should not be handed the server's public hostname. app.get('/api/connection-address', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); try { const tunnel = getTunnelStatus(); if (tunnel && tunnel.active && tunnel.url) { return res.json({ url: tunnel.url, source: 'tunnel' }); } const resolved = baseUrl(req); // Loopback means we could not work out anything shareable. Say so rather // than handing back localhost, so the client can hide the widget instead // of offering to copy an address that only works on this machine. const isLoopback = /^https?:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)(:|$)/i.test(resolved); res.json({ url: isLoopback ? null : resolved, source: isLoopback ? 'loopback' : (process.env.PUBLIC_URL ? 'public_url' : 'host'), }); } catch { res.status(500).json({ error: 'Failed to resolve address' }); } }); // ── Tunnel API (Admin only) ────────────────────────────── app.get('/api/tunnel/status', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user || !verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' }); res.json(getTunnelStatus()); }); app.post('/api/tunnel/sync', express.json(), async (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user || !verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' }); try { // Use values from the request body directly (DB may not have saved yet) const enabled = req.body.enabled === true; const provider = req.body.provider || 'localtunnel'; if (!enabled) await stopTunnel(); else await startTunnel(PORT, provider, useSSL); res.json(getTunnelStatus()); } catch (err) { res.status(500).json({ error: err?.message || 'Tunnel sync failed' }); } }); app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('/app', (req, res) => { res.setHeader('Cache-Control', 'no-cache'); // Inject current version into cache-busting query strings so client // assets are never served stale after an update (especially in Electron). const ver = require('./package.json').version; let html = fs.readFileSync(path.join(__dirname, 'public', 'app.html'), 'utf8'); html = html.replace(/(\?v=)[^"']*/g, `$1${ver}`); res.type('html').send(html); }); // ── Vanity invite link (/invite/:code) ──────────────── // The answer is the same redirect whether or not the code exists, so this // page cannot be used to test guesses at short custom invite slugs; the app // passes ?invite= to join-channel, which checks the code (and its enabled, // expiry and use-limit rules) for a signed-in member. The limit is per IP, // generous for people following links and tight for a guessing script. const inviteLinkLimiter = require('express-rate-limit')({ windowMs: 60 * 1000, max: 30, message: 'Too many invite links, try again in a minute' }); app.get('/invite/:vanityCode', inviteLinkLimiter, (req, res) => { const vanityCode = req.params.vanityCode; if (!vanityCode || typeof vanityCode !== 'string' || !/^[a-zA-Z0-9_-]{3,32}$/.test(vanityCode)) { return res.status(400).send('Invalid invite link'); } res.redirect(`/app?invite=${encodeURIComponent(vanityCode)}`); }); app.get('/games/flappy', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'games', 'flappy.html')); }); // ── Donors / sponsors list (loaded from donors.json) ── app.get('/api/donors', (req, res) => { try { const donorsPath = path.join(__dirname, 'donors.json'); const data = JSON.parse(fs.readFileSync(donorsPath, 'utf-8')); // Check for magnitude-sorted order file (gitignored, optional) const orderPath = path.join(__dirname, 'donor-order.json'); if (fs.existsSync(orderPath)) { try { const ordered = JSON.parse(fs.readFileSync(orderPath, 'utf-8')); data.featuredSponsors = ordered.sponsors || []; data.featuredDonors = ordered.donors || []; } catch { /* optional ordering file; the plain donors list still shows */ } } res.json(data); } catch { res.json({ sponsors: [], donors: [] }); } }); // ── Health check (CORS allowed for multi-server status pings) ── app.get('/api/health', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); res.set('Cross-Origin-Resource-Policy', 'cross-origin'); res.set('Vary', 'Origin'); let name = process.env.SERVER_NAME || 'Haven'; let icon = null; let fingerprint = null; try { const { getDb } = require('./src/database'); const db = getDb(); const row = db.prepare("SELECT value FROM server_settings WHERE key = 'server_name'").get(); if (row && row.value) name = row.value; const iconRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_icon'").get(); if (iconRow && iconRow.value) icon = iconRow.value; const fpRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_fingerprint'").get(); if (fpRow && fpRow.value) fingerprint = fpRow.value; } catch { /* frequent unauthenticated ping: answer with the env defaults rather than fail or log */ } res.json({ status: 'online', name, icon, fingerprint // version intentionally omitted: don't fingerprint the server for attackers }); }); // ── Version endpoint (for update checker, authenticated users only) ── app.get('/api/version', (req, res) => { const pkg = require('./package.json'); res.json({ version: pkg.version }); }); // ── Public config (unauthenticated: safe, read-only aesthetics) ── // Returns the admin-configured default theme so the login page can match // the server's look for first-time visitors who have no localStorage preference. app.get('/api/public-config', (req, res) => { try { const { getDb } = require('./src/database'); const db = getDb(); const themeRow = db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get(); const publishedThemesRow = db.prepare("SELECT value FROM server_settings WHERE key = 'published_themes'").get(); const localeRow = db.prepare("SELECT value FROM server_settings WHERE key = 'default_locale'").get(); const titleRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_title'").get(); const tosRow = db.prepare("SELECT value FROM server_settings WHERE key = 'custom_tos'").get(); const nameRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_name'").get(); const iconRow = db.prepare("SELECT value FROM server_settings WHERE key = 'server_icon'").get(); const adminPwResetRow = db.prepare("SELECT value FROM server_settings WHERE key = 'admin_password_reset_enabled'").get(); const loginSetting = (k) => db.prepare('SELECT value FROM server_settings WHERE key = ?').get(k)?.value || ''; const oidcConfig = require('./src/oidc').getOidcConfig(); let storedPublishedThemes = []; try { storedPublishedThemes = JSON.parse(publishedThemesRow?.value || '[]'); } catch { /* malformed setting: treat as no published themes */ } const publishedThemes = compatibleThemeFiles(THEMES_DIR, storedPublishedThemes); res.json({ default_theme: validatedThemeDefault(THEMES_DIR, themeRow?.value || '', publishedThemes), default_locale: localeRow?.value || '', server_title: titleRow?.value || '', custom_tos: tosRow?.value || '', // Expose name + icon so the login page can brand its tab title and // favicon (issue #5284). These are already public via /api/health. server_name: nameRow?.value || process.env.SERVER_NAME || '', server_icon: iconRow?.value || '', // The login page's look (#5819), all of it shown to anyone who visits. login_logo: loginSetting('login_show_icon') === 'true' ? (iconRow?.value || '') : '', login_background: loginSetting('login_show_banner') === 'true' ? loginSetting('server_banner') : '', login_message: loginSetting('login_message'), login_css: loginSetting('login_css'), // Surface security-relevant settings users may want to know about // before signing up (issue #5300). Allowing a user to *see* whether // an admin can reset their password is the trust-and-warning half // of the feature: admins enable, users get the disclosure. admin_password_reset_enabled: adminPwResetRow?.value === 'true', // SSO (#12). Reports configured-and-usable, not just the toggle, so the // login page never offers a button that can only fail. The issuer and // client id stay server-side; the client needs neither. oidc_enabled: oidcConfig.enabled, oidc_button_label: oidcConfig.enabled ? oidcConfig.buttonLabel : '', tls_issue: tlsIssue }); } catch { res.json({ default_theme: '', default_locale: '', server_title: '' }); } }); // ── Port reachability check (Admin only) ───────────────── // Uses external services to test if this server is reachable from the internet. // Returns { reachable: bool, publicIp: string|null, error: string|null } app.get('/api/port-check', async (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user || !verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' }); const port = process.env.PORT || 3000; const https = require('https'); const http = require('http'); const { agentFor } = require('./src/outboundProxy'); // Step 1: Get public IP let publicIp = null; try { publicIp = await new Promise((resolve, reject) => { const req = https.get('https://api.ipify.org?format=json', { timeout: 5000, agent: agentFor('https://api.ipify.org') }, (resp) => { let data = ''; resp.on('data', chunk => data += chunk); resp.on('end', () => { try { resolve(JSON.parse(data).ip); } catch { reject(new Error('Bad response')); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); }); } catch { return res.json({ reachable: false, publicIp: null, error: 'Could not determine public IP. You may be offline.' }); } // Step 2: Check if port is reachable via external probe let reachable = false; try { reachable = await new Promise((resolve, reject) => { const url = `https://portchecker.io/api/v1/query?host=${publicIp}&ports=${port}`; const req = https.get(url, { timeout: 10000, agent: agentFor(url) }, (resp) => { let data = ''; resp.on('data', chunk => data += chunk); resp.on('end', () => { try { const result = JSON.parse(data); // portchecker.io returns { host, ports: [{ port, status }] } const portResult = result.ports?.find(p => p.port === parseInt(port)); resolve(portResult?.status === 'open'); } catch { resolve(false); } }); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } catch { // Fallback: try to connect to ourselves from public IP try { const proto = useSSL ? https : http; reachable = await new Promise((resolve) => { const req = proto.get(`${useSSL ? 'https' : 'http'}://${publicIp}:${port}/api/health`, { timeout: 5000, // SECURITY NOTE: rejectUnauthorized:false is intentional here: this // connects to OUR OWN public IP to test reachability. Self-signed certs // used by Haven would fail standard verification. This never connects // to third-party servers. rejectUnauthorized: false }, (resp) => { let data = ''; resp.on('data', chunk => data += chunk); resp.on('end', () => { try { resolve(JSON.parse(data).status === 'online'); } catch { resolve(false); } }); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); }); } catch { reachable = false; } } res.json({ reachable, publicIp, error: null }); }); // ── Upload rate limiting ───────────────────────────────── const uploadLimitStore = new Map(); function uploadLimiter(req, res, next) { // Server admins are not throttled: posting a batch of pictures for a guide // or an example is a normal thing for them to do (#5698). The disk guard // and size caps still apply. const token = req.headers.authorization?.split(' ')[1]; const tokenUser = token ? verifyToken(token) : null; if (tokenUser && verifyAdminFromDb(tokenUser)) return next(); const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); const windowMs = 60 * 1000; // 1 minute const maxUploads = 10; if (!uploadLimitStore.has(ip)) uploadLimitStore.set(ip, []); const stamps = uploadLimitStore.get(ip).filter(t => now - t < windowMs); uploadLimitStore.set(ip, stamps); if (stamps.length >= maxUploads) return res.status(429).json({ error: 'Upload rate limit: try again in a minute' }); stamps.push(now); next(); } setInterval(() => { const now = Date.now(); for (const [ip, t] of uploadLimitStore) { const f = t.filter(x => now - x < 60000); if (!f.length) uploadLimitStore.delete(ip); else uploadLimitStore.set(ip, f); } }, 5 * 60 * 1000); // ── Image and file uploads, and Flash ROMs: src/routes/uploads.js ── require('./src/routes/uploads')({ uploadDiskGuard, app, recordUploadOwnership, uploadScopeFromRequest, uploadCapMb, verifyAdminFromDb, userHasPermission, upload, fileUpload, uploadLimiter }); // (duplicate avatar handler removed; handled above at /api/upload-avatar) // ── Sounds, custom emoji and stickers: src/routes/media-library.js ── const { BUILTIN_SOUNDS, seedStarterStickers } = require('./src/routes/media-library')({ uploadDiskGuard, app, verifyAdminFromDb, userHasPermission, uploadDir, uploadStorage, uploadLimiter }); // Profile badge icons (#5632): src/routes/badges.js require('./src/routes/badges')({ app, verifyAdminFromDb, userHasPermission, uploadDir, uploadLimiter, uploadDiskGuard }); // ── GIF search proxy (GIPHY, KLIPY, or a legacy Tenor fallback) ── // Three providers are supported. preferred_gif_search picks which one to use // when more than one key is set. If that preference is unset or not a known // provider, we fall back to whatever is configured, trying GIPHY first, then // KLIPY, and Tenor last (Tenor is deprecated). const GIF_FALLBACK_ORDER = ['giphy', 'klipy', 'tenor']; function getGifProvider() { // Check database first (set via admin panel), fall back to .env const readSetting = (key) => { try { const { getDb } = require('./src/database'); const row = getDb().prepare('SELECT value FROM server_settings WHERE key = ?').get(key); if (row && row.value) return row.value; } catch { /* DB not ready yet or no key stored */ } return ''; }; const keys = { giphy: readSetting('giphy_api_key') || process.env.GIPHY_API_KEY || '', klipy: readSetting('klipy_api_key') || process.env.KLIPY_API_KEY || '', tenor: readSetting('tenor_api_key') || process.env.TENOR_API_KEY || '', }; const preferred = (readSetting('preferred_gif_search') || process.env.PREFERRED_GIF_SEARCH || '') .trim().toLowerCase(); // Honour the preference only when it names a known provider that actually // has a key; otherwise fall through to the configured-order fallback. if (GIF_FALLBACK_ORDER.includes(preferred) && keys[preferred]) { return { provider: preferred, key: keys[preferred] }; } for (const provider of GIF_FALLBACK_ORDER) { if (keys[provider]) return { provider, key: keys[provider] }; } return null; } // All providers normalize to the same result shape the client expects: // { id, title, tiny (grid thumbnail), full (send URL) }. function fetchGifs(kind, q, limit, cfg) { if (cfg.provider === 'klipy') { // The app key is a path segment; the small (220px) gif is the grid // thumbnail and the hd/md gif is the send URL. const path = kind === 'search' ? `gifs/search?q=${encodeURIComponent(q)}&` : 'gifs/trending?'; const url = `https://api.klipy.com/api/v1/${encodeURIComponent(cfg.key)}/${path}per_page=${limit}&content_filter=off`; return fetch(url).then(r => r.json()).then(data => (data.data?.data || []).map(g => ({ id: g.id, title: g.title || '', tiny: g.file?.sm?.gif?.url || g.file?.xs?.gif?.url || '', full: g.file?.hd?.gif?.url || g.file?.md?.gif?.url || '', }))); } if (cfg.provider === 'tenor') { const base = kind === 'search' ? `https://tenor.googleapis.com/v2/search?q=${encodeURIComponent(q)}&` : 'https://tenor.googleapis.com/v2/featured?'; const url = `${base}key=${encodeURIComponent(cfg.key)}&limit=${limit}&media_filter=tinygif,gif&contentfilter=off`; return fetch(url).then(r => r.json()).then(data => (data.results || []).map(g => ({ id: g.id, title: g.content_description || g.title || '', tiny: g.media_formats?.tinygif?.url || g.media_formats?.gif?.url || '', full: g.media_formats?.gif?.url || '', }))); } const base = kind === 'search' ? `https://api.giphy.com/v1/gifs/search?q=${encodeURIComponent(q)}&lang=en&` : 'https://api.giphy.com/v1/gifs/trending?'; const url = `${base}api_key=${encodeURIComponent(cfg.key)}&limit=${limit}&rating=r`; return fetch(url).then(r => r.json()).then(data => (data.data || []).map(g => ({ id: g.id, title: g.title || '', tiny: g.images?.fixed_height_small?.url || g.images?.fixed_height?.url || '', full: g.images?.original?.url || '', }))); } // ── Server icon upload (admin only, image only, max 2 MB) ── app.post('/api/upload-server-icon', uploadLimiter, uploadDiskGuard, (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' }); upload.single('image')(req, res, async (err) => { if (err) return res.status(400).json({ error: err.message, code: err.code }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (req.file.size > 2 * 1024 * 1024) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Server icon must be under 2 MB' }); } // Validate magic bytes try { const fd = fs.openSync(req.file.path, 'r'); const hdr = Buffer.alloc(12); fs.readSync(fd, hdr, 0, 12, 0); fs.closeSync(fd); let validMagic = false; if (req.file.mimetype === 'image/jpeg') validMagic = hdr[0] === 0xFF && hdr[1] === 0xD8 && hdr[2] === 0xFF; else if (req.file.mimetype === 'image/png') validMagic = hdr[0] === 0x89 && hdr[1] === 0x50 && hdr[2] === 0x4E && hdr[3] === 0x47; else if (req.file.mimetype === 'image/gif') validMagic = hdr.slice(0, 6).toString().startsWith('GIF8'); else if (req.file.mimetype === 'image/webp') validMagic = hdr.slice(0, 4).toString() === 'RIFF' && hdr.slice(8, 12).toString() === 'WEBP'; if (!validMagic) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid image' }); } } catch { try { fs.unlinkSync(req.file.path); } catch { /* rejected temp upload may already be gone */ } return res.status(400).json({ error: 'Failed to validate' }); } try { await storage.admitUpload(req.file); } catch { return res.status(500).json({ error: 'Failed to store the upload' }); } const iconUrl = `/uploads/${req.file.filename}`; const { getDb } = require('./src/database'); getDb().prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('server_icon', ?)").run(iconUrl); auditHttp(user, 'server_setting_update', { target_type: 'setting', target_name: 'server_icon', details: { key: 'server_icon', value: iconUrl } }); res.json({ url: iconUrl }); }); }); // ── Role icon upload (admin only, image only, max 512 KB) ── app.post('/api/upload-role-icon', uploadLimiter, uploadDiskGuard, (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); if (!verifyAdminFromDb(user) && !userHasPermission(user.id, 'manage_roles')) { return res.status(403).json({ error: 'Admin or manage_roles permission required' }); } upload.single('icon')(req, res, async (err) => { if (err) return res.status(400).json({ error: err.message, code: err.code }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (req.file.size > 512 * 1024) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Role icon must be under 512 KB' }); } try { const fd = fs.openSync(req.file.path, 'r'); const hdr = Buffer.alloc(12); fs.readSync(fd, hdr, 0, 12, 0); fs.closeSync(fd); let validMagic = false; if (req.file.mimetype === 'image/jpeg') validMagic = hdr[0] === 0xFF && hdr[1] === 0xD8 && hdr[2] === 0xFF; else if (req.file.mimetype === 'image/png') validMagic = hdr[0] === 0x89 && hdr[1] === 0x50 && hdr[2] === 0x4E && hdr[3] === 0x47; else if (req.file.mimetype === 'image/gif') validMagic = hdr.slice(0, 6).toString().startsWith('GIF8'); else if (req.file.mimetype === 'image/webp') validMagic = hdr.slice(0, 4).toString() === 'RIFF' && hdr.slice(8, 12).toString() === 'WEBP'; if (!validMagic) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid image' }); } } catch { try { fs.unlinkSync(req.file.path); } catch { /* rejected temp upload may already be gone */ } return res.status(400).json({ error: 'Failed to validate' }); } try { await storage.admitUpload(req.file); } catch { return res.status(500).json({ error: 'Failed to store the upload' }); } const iconUrl = `/uploads/${req.file.filename}`; res.json({ path: iconUrl }); }); }); // ── Backup download and restore (admin only): src/routes/backup.js ── const { buildBackupFile } = require('./src/routes/backup')({ app, verifyAdminFromDb, late }); // ── Server banner upload (admin only, image only, max 4 MB) ── app.post('/api/upload-server-banner', uploadLimiter, uploadDiskGuard, (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' }); upload.single('image')(req, res, async (err) => { if (err) return res.status(400).json({ error: err.message, code: err.code }); if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); if (req.file.size > 4 * 1024 * 1024) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Server banner must be under 4 MB' }); } try { const fd = fs.openSync(req.file.path, 'r'); const hdr = Buffer.alloc(12); fs.readSync(fd, hdr, 0, 12, 0); fs.closeSync(fd); const isJpeg = hdr[0] === 0xFF && hdr[1] === 0xD8 && hdr[2] === 0xFF; const isPng = hdr[0] === 0x89 && hdr[1] === 0x50 && hdr[2] === 0x4E && hdr[3] === 0x47; const isGif = hdr.slice(0, 6).toString().startsWith('GIF8'); const isWebp = hdr.slice(0, 4).toString() === 'RIFF' && hdr.slice(8, 12).toString() === 'WEBP'; if (!isJpeg && !isPng && !isGif && !isWebp) { fs.unlinkSync(req.file.path); return res.status(400).json({ error: 'Invalid image: only JPG, PNG, GIF, or WebP' }); } } catch { try { fs.unlinkSync(req.file.path); } catch { /* rejected temp upload may already be gone */ } return res.status(400).json({ error: 'Failed to validate' }); } try { await storage.admitUpload(req.file); } catch { return res.status(500).json({ error: 'Failed to store the upload' }); } const bannerUrl = `/uploads/${req.file.filename}`; const { getDb } = require('./src/database'); getDb().prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('server_banner', ?)").run(bannerUrl); auditHttp(user, 'server_setting_update', { target_type: 'setting', target_name: 'server_banner', details: { key: 'server_banner', value: bannerUrl } }); res.json({ url: bannerUrl }); }); }); // ── GIF endpoint rate limiting (per IP) ────────────────── const gifLimitStore = new Map(); function gifLimiter(req, res, next) { const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); const windowMs = 60 * 1000; // 1 minute const maxReqs = 30; if (!gifLimitStore.has(ip)) gifLimitStore.set(ip, []); const stamps = gifLimitStore.get(ip).filter(t => now - t < windowMs); gifLimitStore.set(ip, stamps); if (stamps.length >= maxReqs) return res.status(429).json({ error: 'Rate limited, try again shortly' }); stamps.push(now); next(); } setInterval(() => { const now = Date.now(); for (const [ip, t] of gifLimitStore) { const f = t.filter(x => now - x < 60000); if (!f.length) gifLimitStore.delete(ip); else gifLimitStore.set(ip, f); } }, 5 * 60 * 1000); app.get('/api/gif/search', gifLimiter, (req, res) => { // Require authentication const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const cfg = getGifProvider(); if (!cfg) return res.status(501).json({ error: 'gif_not_configured' }); const q = (req.query.q || '').trim().slice(0, 100); if (!q) return res.status(400).json({ error: 'Missing search query' }); const limit = Math.min(parseInt(req.query.limit) || 20, 50); fetchGifs('search', q, limit, cfg) .then(results => res.json({ provider: cfg.provider, results })) .catch(() => res.status(502).json({ error: 'GIF provider API error' })); }); app.get('/api/gif/trending', gifLimiter, (req, res) => { // Require authentication const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const cfg = getGifProvider(); if (!cfg) return res.status(501).json({ error: 'gif_not_configured' }); const limit = Math.min(parseInt(req.query.limit) || 20, 50); fetchGifs('trending', '', limit, cfg) .then(results => res.json({ provider: cfg.provider, results })) .catch(() => res.status(502).json({ error: 'GIF provider API error' })); }); // ── Link previews and the media proxy: src/routes/link-preview.js ── require('./src/routes/link-preview')({ app }); // ── Games list endpoint: discover available games ── app.get('/api/games', (req, res) => { const gamesDir = path.join(__dirname, 'public', 'games'); const fs2 = require('fs'); try { const entries = fs2.readdirSync(gamesDir, { withFileTypes: true }); const games = entries .filter(e => e.isFile() && e.name.endsWith('.html')) .map(e => e.name.replace('.html', '')); res.json({ games }); } catch { res.json({ games: [] }); } }); // ── High-scores REST API (mobile-safe fallback for postMessage) ── app.get('/api/high-scores/:game', (req, res) => { const game = req.params.game; if (!/^[a-z0-9_-]{1,32}$/.test(game)) return res.status(400).json({ error: 'Invalid game id' }); const { getDb } = require('./src/database'); // Answered without a login (the game's fallback cannot send one), so it // carries names and scores only, not account ids. const leaderboard = getDb().prepare(` SELECT COALESCE(u.display_name, u.username) as username, hs.score FROM high_scores hs JOIN users u ON hs.user_id = u.id WHERE hs.game = ? AND hs.score > 0 AND NOT EXISTS ( SELECT 1 FROM user_preferences up WHERE up.user_id = u.id AND up.key = 'hide_score_badge' AND up.value = 'true' ) ORDER BY hs.score DESC LIMIT 50 `).all(game); res.json({ game, leaderboard }); }); app.post('/api/high-scores', express.json(), (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const game = typeof req.body.game === 'string' ? req.body.game.trim() : ''; if (!game || !/^[a-z0-9_-]{1,32}$/.test(game) || !isKnownGame(game)) return res.status(400).json({ error: 'Invalid game id' }); // A JSON number only: no strings, and nothing past the game's ceiling. const score = typeof req.body.score === 'number' ? validHighScore(game, req.body.score) : null; if (score === null) return res.status(400).json({ error: 'Invalid score' }); const { getDb } = require('./src/database'); const db = getDb(); const current = db.prepare('SELECT score FROM high_scores WHERE user_id = ? AND game = ?').get(user.id, game); if (!current || score > current.score) { db.prepare( "INSERT OR REPLACE INTO high_scores (user_id, game, score, updated_at) VALUES (?, ?, ?, datetime('now'))" ).run(user.id, game, score); } const leaderboard = db.prepare(` SELECT hs.user_id, COALESCE(u.display_name, u.username) as username, hs.score FROM high_scores hs JOIN users u ON hs.user_id = u.id WHERE hs.game = ? AND hs.score > 0 AND NOT EXISTS ( SELECT 1 FROM user_preferences up WHERE up.user_id = u.id AND up.key = 'hide_score_badge' AND up.value = 'true' ) ORDER BY hs.score DESC LIMIT 50 `).all(game); res.json({ game, leaderboard }); }); // ═══════════════════════════════════════════════════════════ // WEBHOOK / BOT INTEGRATION: incoming message endpoint // ═══════════════════════════════════════════════════════════ const rateLimit = require('express-rate-limit'); const webhookLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { error: 'Rate limit exceeded' } }); const webhookAudioLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, message: { error: 'Audio rate limit exceeded' } }); const webhookAudioControlLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { error: 'Audio control rate limit exceeded' } }); const botAudioPlaybackLimiter = rateLimit({ windowMs: 60 * 1000, max: 120, message: { error: 'Audio playback rate limit exceeded' } }); app.post('/api/webhooks/:token', webhookLimiter, express.json({ limit: '64kb' }), (req, res) => { const { getDb } = require('./src/database'); const db = getDb(); const { token } = req.params; if (!token || typeof token !== 'string' || token.length !== 64) { return res.status(400).json({ error: 'Invalid token' }); } // Same rule as getWebhookByToken: a banned person's bots stop posting. const webhook = db.prepare( `SELECT w.*, c.code as channel_code, c.name as channel_name FROM webhooks w JOIN channels c ON w.channel_id = c.id WHERE w.token = ? AND w.is_active = 1 AND c.is_dm = 0 AND ${CREATOR_NOT_BANNED_SQL}` ).get(token); if (!webhook) { return res.status(404).json({ error: 'Webhook not found or inactive' }); } // A bot can post silently too: "silent": true, or "@silent" up front. const silentPick = takeSilent(typeof req.body.content === 'string' ? req.body.content.trim() : '', req.body.silent); const silent = silentPick.silent; const content = sanitizeText(silentPick.content.trim()); if (!content || content.length > 4000) { return res.status(400).json({ error: 'Content required (max 4000 chars)' }); } // Optional overrides per-message const username = (typeof req.body.username === 'string' && cleanWebhookName(sanitizeText(req.body.username))) || webhook.name; // Only this server's uploads or https images (loaded through the media // proxy), never a plain http or other scheme URL every reader fetches. let avatarUrl = webhook.avatar_url; if (typeof req.body.avatar_url === 'string') avatarUrl = cleanWebhookAvatarUrl(req.body.avatar_url); // Optional reply_to: bot replying to a message in the same channel (3.13.0) let replyTo = null; if (req.body.reply_to !== undefined && req.body.reply_to !== null) { const rid = parseInt(req.body.reply_to, 10); if (Number.isInteger(rid) && rid > 0) { const target = db.prepare('SELECT id FROM messages WHERE id = ? AND channel_id = ?').get(rid, webhook.channel_id); if (target) replyTo = rid; } } // Optional thread_id: post as a reply inside a thread (#5706). The parent // has to be a top-level message in this bot's channel, and a reply_to // only stands if it points into that same thread. let threadId = null; if (req.body.thread_id !== undefined && req.body.thread_id !== null) { const tid = parseInt(req.body.thread_id, 10); const parent = Number.isInteger(tid) && tid > 0 ? db.prepare('SELECT id FROM messages WHERE id = ? AND channel_id = ? AND thread_id IS NULL').get(tid, webhook.channel_id) : null; if (!parent) return res.status(400).json({ error: 'thread_id must be a top-level message in this bot\'s channel' }); if (req.body.ephemeral === true) return res.status(400).json({ error: 'thread_id cannot be combined with ephemeral' }); threadId = tid; if (replyTo) { const r = db.prepare('SELECT thread_id FROM messages WHERE id = ?').get(replyTo); if (!r || r.thread_id !== threadId) replyTo = null; } } // Optional ephemeral delivery to a single recipient in this channel. // Ephemeral webhook messages are not persisted to chat history. const ephemeral = req.body.ephemeral === true; let recipientId = null; if (ephemeral) { const parsedRecipientId = parseInt(req.body.recipient_id, 10); if (!Number.isInteger(parsedRecipientId) || parsedRecipientId < 1) { return res.status(400).json({ error: 'recipient_id is required when ephemeral is true' }); } const member = db.prepare('SELECT 1 FROM channel_members WHERE channel_id = ? AND user_id = ?').get(webhook.channel_id, parsedRecipientId); if (!member) { return res.status(400).json({ error: 'recipient_id must be a member of this channel' }); } recipientId = parsedRecipientId; } // Build replyContext if this is a reply (so the client renders the inline preview) let replyContext = null; if (replyTo) { try { const r = db.prepare(` SELECT m.id, m.content, m.user_id, m.is_webhook, m.webhook_username, m.imported_from, m.persona_username, COALESCE(u.display_name, u.username) AS username FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ? `).get(replyTo); if (r) { replyContext = toReplyContext({ ...r, content: (r.content || '').slice(0, 200), }); } } catch { /* the inline reply preview is cosmetic; the message still posts without it */ } } const message = { id: null, content, created_at: new Date().toISOString(), username: `[BOT] ${username}`, user_id: null, avatar: avatarUrl || null, avatar_shape: 'square', reply_to: replyTo, replyContext, reactions: [], is_webhook: true, webhook_name: username, ephemeral, recipient_id: recipientId, thread_id: threadId, silent: silent || undefined, }; if (ephemeral) { let deliveredSockets = 0; if (io) { const nsp = io.of('/'); for (const [, s] of nsp.sockets) { if (s.user && s.user.id === recipientId) { s.emit('new-message', { channelCode: webhook.channel_code, message }); deliveredSockets++; } } } return res.status(200).json({ success: true, ephemeral: true, recipient_id: recipientId, delivered: deliveredSockets > 0 }); } // Insert non-ephemeral messages into the DB/history. const result = db.prepare( 'INSERT INTO messages (channel_id, user_id, content, is_webhook, webhook_username, webhook_avatar, reply_to, thread_id, webhook_id, silent) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?)' ).run(webhook.channel_id, null, content, username, avatarUrl || null, replyTo, threadId, webhook.id, silent ? 1 : 0); message.id = result.lastInsertRowid; // Broadcast to all clients in this channel if (io && threadId) { // A thread reply: the same two events a person's thread reply sends. const code = webhook.channel_code; io.to(`channel:${code}`).emit('new-thread-message', { channelCode: code, parentId: threadId, message, followerIds: threadNotify.followerIds(db, threadId) }); const count = db.prepare('SELECT COUNT(*) AS count FROM messages WHERE thread_id = ?').get(threadId).count; const last = db.prepare('SELECT id, created_at FROM messages WHERE thread_id = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(threadId); const participants = db.prepare(` SELECT DISTINCT COALESCE(m.webhook_username, u.display_name, u.username) AS username, COALESCE(m.webhook_avatar, u.avatar) AS avatar FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 5 `).all(threadId); io.to(`channel:${code}`).emit('thread-updated', { channelCode: code, parentId: threadId, thread: { count, lastReplyAt: last ? last.created_at : null, lastReplyId: last ? last.id : null, senderId: null, participants: participants.map(p => ({ username: p.username, avatar: p.avatar })), // A bot's reply is always a comment in a blog-mode forum (#5742). ...forumBlogThreadExtras(db, webhook.channel_id, threadId), }, }); } else if (io) { io.to(`channel:${webhook.channel_code}`).emit('new-message', { channelCode: webhook.channel_code, message }); } res.status(200).json({ success: true, message_id: result.lastInsertRowid, ...(threadId ? { thread_id: threadId } : {}) }); }); // ── Listening presence webhook (any music player) ── // A reserved path, deliberately kept off the generic /api/webhooks/:token bot // route (different segment count, so the two never collide). A user generates // the token from Settings → Activity → Listening; any player's plugin or a // small script posts presence here (title/artist/album/position/duration + // optional cover bytes). See docs/listening-api.md for the full contract. // // Strict on purpose: this is an unauthenticated-by-header endpoint reachable by // a user-generated token, and the cover bytes are served back to other users. const MAX_LISTENING_DURATION = 24 * 60 * 60; // cap seconds so a phony duration can't linger const listeningLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, message: { error: 'Rate limit exceeded' } }); // The cover route is viewer-facing (every open profile card fetches it), so it // gets a looser cap than the single-poster webhook while still bounding abuse. const listeningCoverLimiter = rateLimit({ windowMs: 60 * 1000, max: 120, message: { error: 'Rate limit exceeded' } }); const listeningUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 512 * 1024, files: 1, fields: 8, fieldSize: 4096 }, }).single('cover'); app.post('/api/webhooks/listening/:token', listeningLimiter, (req, res) => { // Resolve the token before touching the body, so a post to an unknown or // malformed token is refused without buffering up to 512KB of upload first. const { token } = req.params; if (!token || typeof token !== 'string' || !/^[0-9a-f]{64}$/i.test(token)) { return res.status(400).json({ error: 'Invalid token' }); } const row = require('./src/database').getDb() .prepare('SELECT user_id FROM listening_tokens WHERE token = ?') .get(token); if (!row) return res.status(404).json({ error: 'Webhook not found' }); listeningUpload(req, res, (err) => { if (err) return res.status(400).json({ error: 'Invalid upload' }); const engine = activityRef.engine; if (!engine) return res.sendStatus(204); const b = req.body || {}; const state = typeof b.state === 'string' ? b.state : ''; // Stop/expired clears the profile; no track fields needed. if (state === 'stopped' || state === 'expired') { engine.clearListeningPresence(row.user_id); return res.sendStatus(204); } // Cover is optional, but when present the bytes must be a real image. let cover = null, coverType = null; if (req.file && req.file.buffer && req.file.buffer.length) { coverType = sniffImageType(req.file.buffer); if (!coverType) return res.status(400).json({ error: 'Unsupported cover format' }); cover = req.file.buffer; } const hasTitle = typeof b.title === 'string' && b.title.trim(); // An empty body is a reachability ping (or nothing playing); accept it. if (!cover && !hasTitle) return res.sendStatus(204); if (!hasTitle) return res.status(400).json({ error: 'Title required' }); // Every track has a duration by definition; reject anything without one and // cap it so a track can't schedule a runaway expiry. let duration = parseInt(b.duration, 10); if (!duration || duration <= 0) return res.status(400).json({ error: 'Duration required' }); duration = Math.min(duration, MAX_LISTENING_DURATION); engine.setListeningPresence(row.user_id, { title: b.title, artist: typeof b.artist === 'string' ? b.artist : '', album: typeof b.album === 'string' ? b.album : '', position: parseInt(b.position, 10) || 0, duration, cover, coverType, paused: state === 'paused', heartbeat: state === 'heartbeat', source: typeof b.source === 'string' ? b.source : '', }); return res.sendStatus(204); }); }); // Serves a user's current listening cover from memory. The opaque version must // match, so the URL only works for someone who received the current presence. app.get('/api/activity/listening-cover/:userId', listeningCoverLimiter, (req, res) => { const engine = activityRef.engine; const userId = parseInt(req.params.userId, 10); const cover = engine && !isNaN(userId) ? engine.getListeningCover(userId) : null; if (!cover || req.query.v !== cover.version) return res.sendStatus(404); res.setHeader('Content-Type', cover.contentType); res.setHeader('Cache-Control', 'private, max-age=300'); return res.end(cover.buf); }); // Voice presence is scoped to the bot's assigned channel, channels its // creator belongs to, or every non-DM channel when its creator is an admin. app.get('/api/webhooks/:token/voice/channels', webhookLimiter, (req, res) => { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); if (!webhook.can_use_voice) { return res.status(403).json({ error: 'This bot does not have voice permission' }); } const { getDb } = require('./src/database'); const voiceUsers = socketRuntime?.state?.voiceUsers; const channels = getAccessibleVoiceChannels(getDb(), webhook) .filter(channel => channel.voice_enabled !== 0) .map(channel => { const room = voiceUsers?.get(channel.code); const users = room ? Array.from(room.values()) : []; return { code: channel.code, name: channel.name, members: users.filter(user => !user.isBot).length, bots: users.filter(user => user.isBot).length }; }) .sort((a, b) => b.members - a.members || a.name.localeCompare(b.name)); res.json({ channels }); }); app.get('/api/bot-audio/:playbackId/:accessToken', botAudioPlaybackLimiter, (req, res) => { if (!/^[a-f0-9-]{36}$/i.test(req.params.playbackId) || !/^[a-f0-9]{48}$/i.test(req.params.accessToken)) { return res.status(404).end(); } const playable = botAudioManager?.getPlayable(req.params.playbackId, req.params.accessToken); if (!playable) return res.status(404).json({ error: 'Audio is unavailable or expired' }); res.set('Cache-Control', 'private, no-store, max-age=0'); res.type(playable.mime); return res.sendFile(playable.filePath, err => { if (err && !res.headersSent) res.status(err.statusCode || 404).end(); }); }); app.post( '/api/webhooks/:token/audio', webhookAudioLimiter, requireWebhookVoice, uploadDiskGuard, (req, res) => { let uploadAborted = false; const removeUpload = () => { const filePath = req.botAudioTempPath; if (!filePath) return; req.botAudioTempPath = null; fs.promises.unlink(filePath).catch(err => { if (err?.code !== 'ENOENT') console.error('Failed to clean bot audio upload:', err); }); }; res.on('close', () => { if (!res.writableEnded && req.destroyed) { uploadAborted = true; removeUpload(); } }); botAudioUpload.single('audio')(req, res, async uploadError => { if (uploadError) { removeUpload(); const tooLarge = uploadError instanceof multer.MulterError && uploadError.code === 'LIMIT_FILE_SIZE'; return res.status(tooLarge ? 413 : 400).json({ error: tooLarge ? 'Audio must be 10 MB or smaller' : uploadError.message }); } if (!req.file) { removeUpload(); return res.status(400).json({ error: 'An audio file is required' }); } req.botAudioTempPath = req.file.path; try { if (!botAudioManager) throw Object.assign(new Error('Audio service is unavailable'), { status: 503 }); const requestedCode = typeof req.body?.channel_code === 'string' ? req.body.channel_code.trim() : ''; if (requestedCode && requestedCode !== req.botVoiceChannelCode) { throw Object.assign(new Error('Bot is not connected to the requested voice channel'), { status: 409 }); } const inspected = await inspectAudioFile(req.file.path); // Inspection is asynchronous; repeat every authorization and presence // check so a revoke, channel move, or voice leave cannot race enqueue. const webhook = getWebhookByToken(req.params.token); if (!webhook) throw Object.assign(new Error('Invalid bot token'), { status: 401 }); if (!webhook.can_use_voice) { throw Object.assign(new Error('Bot voice permission is required'), { status: 403 }); } const channelCode = resolveCurrentBotVoiceChannel(webhook, req.botVoiceChannelCode); if (!channelCode) { throw Object.assign(new Error('Bot left or changed voice channels during upload'), { status: 409 }); } const playbackId = crypto.randomUUID(); const accessToken = crypto.randomBytes(24).toString('hex'); const finalPath = path.join(BOT_AUDIO_DIR, `${playbackId}${inspected.extension}`); await fs.promises.rename(req.file.path, finalPath); req.botAudioTempPath = finalPath; // Nothing asynchronous may occur between this final check and enqueue. // That closes leave/revoke/delete/rotation/abort races during rename. if (uploadAborted) throw Object.assign(new Error('Audio upload was aborted'), { status: 400 }); const finalWebhook = getWebhookByToken(req.params.token); if (!finalWebhook) throw Object.assign(new Error('Invalid bot token'), { status: 401 }); if (!finalWebhook.can_use_voice) { throw Object.assign(new Error('Bot voice permission is required'), { status: 403 }); } const finalChannelCode = resolveCurrentBotVoiceChannel(finalWebhook, channelCode); if (!finalChannelCode) { throw Object.assign(new Error('Bot left or changed voice channels during upload'), { status: 409 }); } const queued = botAudioManager.enqueue({ playbackId, accessToken, audioUrl: `/api/bot-audio/${encodeURIComponent(playbackId)}/${accessToken}`, webhookId: finalWebhook.id, botName: finalWebhook.name, channelCode: finalChannelCode, filePath: finalPath, mime: inspected.mime, durationMs: inspected.durationMs }); if (queued.error) throw Object.assign(new Error(queued.error), { status: 409 }); req.botAudioTempPath = null; return res.status(202).json({ success: true, playback_id: playbackId, channel_code: finalChannelCode, duration_ms: inspected.durationMs, position: queued.position, queued: queued.queued }); } catch (err) { removeUpload(); return res.status(err.status || 400).json({ error: err.message || 'Audio upload failed' }); } }); } ); app.post( '/api/webhooks/:token/audio/skip', webhookAudioControlLimiter, requireWebhookVoice, (req, res) => { const requestedCode = typeof req.body?.channel_code === 'string' ? req.body.channel_code : ''; const channelCode = resolveCurrentBotVoiceChannel(req.botWebhook, requestedCode); if (!channelCode) return res.status(409).json({ error: 'Bot is not connected to the requested voice channel' }); const result = botAudioManager?.skip(channelCode, req.botWebhook.id) || { skipped: false }; return res.json({ success: true, ...result, channel_code: channelCode }); } ); app.delete( '/api/webhooks/:token/audio/current', webhookAudioControlLimiter, requireWebhookVoice, (req, res) => { const requestedCode = typeof req.body?.channel_code === 'string' ? req.body.channel_code : (typeof req.query.channel_code === 'string' ? req.query.channel_code : ''); const channelCode = resolveCurrentBotVoiceChannel(req.botWebhook, requestedCode); if (!channelCode) return res.status(409).json({ error: 'Bot is not connected to the requested voice channel' }); const result = botAudioManager?.stop(channelCode, req.botWebhook.id) || { stopped: false, removed: 0 }; return res.json({ success: true, ...result, channel_code: channelCode }); } ); // ── Bot: Delete a message in the webhook's channel ────── app.delete('/api/webhooks/:token/messages/:messageId', webhookLimiter, async (req, res) => { const { getDb } = require('./src/database'); const db = getDb(); const { token, messageId } = req.params; const webhook = getWebhookByToken(token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); const mid = parseInt(messageId, 10); if (!Number.isInteger(mid) || mid < 1) return res.status(400).json({ error: 'Invalid message ID' }); const msg = db.prepare('SELECT id, content, channel_id, is_webhook, webhook_id FROM messages WHERE id = ? AND channel_id = ?').get(mid, webhook.channel_id); if (!msg) return res.status(404).json({ error: 'Message not found in this channel' }); // A bot deletes its own messages. Anyone else's, people's or another // bot's, needs a bot with moderation permission, as the bulk delete below // already required. if (!webhook.can_moderate && !(msg.is_webhook && msg.webhook_id === webhook.id)) { return res.status(403).json({ error: 'This bot can only delete its own messages' }); } // Someone else's message also needs the bot's creator to still hold // delete_message here, as for the kick, ban and mute routes. const ownMessage = !!msg.is_webhook && msg.webhook_id === webhook.id; if (!ownMessage && !botModAuth(webhook, 'delete_message', webhook.channel_id)) { return res.status(403).json({ error: BOT_MOD_DENIED }); } const kept = protectedMessages.refusal(db, mid); if (kept) return res.status(409).json({ error: kept }); const replies = db.prepare('SELECT content FROM messages WHERE thread_id = ?').all(mid); const botAuthor = ownMessage ? null : db.prepare('SELECT COALESCE(u.display_name, u.username, m.webhook_username) AS name FROM messages m LEFT JOIN users u ON u.id = m.user_id WHERE m.id = ?').get(mid); try { db.prepare('DELETE FROM pinned_messages WHERE message_id = ?').run(mid); db.prepare('DELETE FROM reactions WHERE message_id = ?').run(mid); db.prepare('DELETE FROM messages WHERE id = ?').run(mid); } catch (err) { console.error('Bot delete message error:', err); return res.status(500).json({ error: 'Failed to delete message' }); } if (!ownMessage) { const auth = botModAuth(webhook, 'delete_message', webhook.channel_id); const chName = db.prepare('SELECT name FROM channels WHERE id = ?').get(webhook.channel_id)?.name || null; auditHttp(auth && auth.user, 'message_delete', { target_type: 'message', target_id: mid, target_name: botAuthor ? botAuthor.name : null, details: { channel: chName, via: `bot ${webhook.name}` } }); } // Its files, and its replies' files, go only when nothing else uses them // and they were uploaded to a channel. Moving every /uploads/ path the text // named let a message that quoted someone's avatar or an emoji take that // file away from everyone. try { await relocateUnreferencedUploads(db, collectUploadRelPaths([msg.content, ...replies.map(r => r.content)])); } catch (err) { console.error('Bot delete attachment cleanup error:', err); } // Find channel code for broadcasting const channel = db.prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id); if (channel && io) { io.to(`channel:${channel.code}`).emit('message-deleted', { channelCode: channel.code, messageId: mid }); } res.json({ success: true }); }); // ── Bot: Delete recent messages and their replies ───────── // Protected messages, and thread heads holding one, are left out. app.delete('/api/webhooks/:token/messages', webhookLimiter, async (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const rawLimit = req.query.limit; if (typeof rawLimit !== 'string' || !/^[1-9]\d*$/.test(rawLimit)) { return res.status(400).json({ error: 'limit must be an integer between 1 and 100' }); } const limit = Number(rawLimit); if (!Number.isSafeInteger(limit) || limit > 100) { return res.status(400).json({ error: 'limit must be an integer between 1 and 100' }); } const { getDb } = require('./src/database'); const db = getDb(); const channel = db.prepare('SELECT id, code FROM channels WHERE id = ?').get(webhook.channel_id); if (!channel) return res.status(404).json({ error: 'Channel not found' }); // The creator must still hold delete_message in this channel. if (!botModAuth(webhook, 'delete_message', channel.id)) return res.status(403).json({ error: BOT_MOD_DENIED }); const selectMessages = db.prepare(` WITH RECURSIVE roots(id) AS ( SELECT id FROM ( SELECT id FROM messages WHERE channel_id = ? AND thread_id IS NULL ORDER BY created_at DESC, id DESC LIMIT ? ) ), doomed(id) AS ( SELECT id FROM roots UNION SELECT m.id FROM messages m JOIN doomed d ON m.reply_to = d.id UNION SELECT m.id FROM messages m JOIN doomed d ON m.thread_id = d.id ) SELECT m.id, m.channel_id, m.content FROM messages m JOIN doomed d ON d.id = m.id WHERE ${protectedMessages.deletableSql('m')} ORDER BY m.id DESC `); const deletePin = db.prepare('DELETE FROM pinned_messages WHERE message_id = ?'); const deleteReactions = db.prepare('DELETE FROM reactions WHERE message_id = ?'); const deleteMessage = db.prepare('DELETE FROM messages WHERE id = ? AND channel_id = ?'); const purge = db.transaction(() => { const messages = selectMessages.all(channel.id, limit); if (messages.some(message => message.channel_id !== channel.id)) { const error = new Error('Related replies exist in another channel'); error.statusCode = 409; throw error; } for (const message of messages) { deletePin.run(message.id); deleteReactions.run(message.id); } for (const message of messages) deleteMessage.run(message.id, channel.id); return messages; }); let deletedMessages; try { deletedMessages = purge(); } catch (err) { console.error('Bot bulk delete messages error:', err); const status = err?.statusCode === 409 ? 409 : 500; const error = status === 409 ? err.message : 'Failed to delete messages'; return res.status(status).json({ error }); } if (deletedMessages.length) { const auth = botModAuth(webhook, 'delete_message', channel.id); const chName = db.prepare('SELECT name FROM channels WHERE id = ?').get(channel.id)?.name || null; auditHttp(auth && auth.user, 'messages_bulk_delete', { target_type: 'channel', target_id: channel.id, target_name: chName, details: { count: deletedMessages.length, via: `bot ${webhook.name}` } }); } try { await relocateUnreferencedUploads( db, collectUploadRelPaths(deletedMessages.map(message => message.content)) ); } catch (err) { console.error('Bot bulk delete attachment cleanup error:', err); } if (io) { for (const message of deletedMessages) { io.to(`channel:${channel.code}`).emit('message-deleted', { channelCode: channel.code, messageId: message.id }); } } return res.json({ success: true, deleted: deletedMessages.length }); }); // ── Bot: Play a soundboard sound in the webhook's channel ── app.post('/api/webhooks/:token/sounds', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); const soundName = typeof req.body.sound === 'string' ? req.body.sound.trim() : ''; if (!soundName) return res.status(400).json({ error: 'sound name required' }); // Verify the sound exists const { getDb } = require('./src/database'); const builtin = BUILTIN_SOUNDS.find(s => s.name === soundName); let soundUrl; if (builtin) { soundUrl = builtin.url; } else { const custom = getDb().prepare('SELECT filename FROM custom_sounds WHERE name = ?').get(soundName); if (!custom) return res.status(404).json({ error: 'Sound not found' }); soundUrl = `/uploads/${custom.filename}`; } // Find the channel code and broadcast the sound event const channel = getDb().prepare('SELECT code FROM channels WHERE id = ?').get(webhook.channel_id); if (!channel) return res.status(404).json({ error: 'Channel not found' }); if (io) { io.to(`channel:${channel.code}`).emit('play-sound', { channelCode: channel.code, soundUrl, soundName, botName: webhook.name }); } res.json({ success: true }); }); // ═══════════════════════════════════════════════════════════ // MODERATION REST API // ═══════════════════════════════════════════════════════════ const modLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { error: 'Rate limit exceeded' } }); // Helper: get authenticated user from Bearer token with admin/mod check // Moderation over HTTP follows the rules the app follows: the permission // must be held server-wide (a role held in one channel does not count; the // helper above counts it), the caller must not be banned, and the target must // rank below the caller. These routes skipped the rank checks, so a Mod, or // anyone who had created a channel, could mute or kick an admin. function getModUser(req, permission) { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return { error: 'Unauthorized', status: 401 }; const { getDb } = require('./src/database'); if (getDb().prepare('SELECT 1 FROM bans WHERE user_id = ?').get(user.id)) { return { error: 'Insufficient permissions', status: 403 }; } const isAdmin = verifyAdminFromDb(user); if (!isAdmin && !(socketRuntime && socketRuntime.userHasPermission(user.id, permission))) { return { error: 'Insufficient permissions', status: 403 }; } return { user, isAdmin }; } function modOutranks(auth, targetId) { if (!auth || !auth.user || targetId === auth.user.id) return false; const { getDb } = require('./src/database'); const target = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(targetId); if (target && target.is_admin) return false; if (auth.isAdmin) return true; if (!socketRuntime) return false; return socketRuntime.getUserEffectiveLevel(targetId) < socketRuntime.getUserEffectiveLevel(auth.user.id); } // Undoing a ban or mute: an admin's stands, as does one placed by someone of // equal or higher rank. function modMayUndo(auth, placedBy) { if (!auth || !auth.user) return false; if (auth.isAdmin || !placedBy || placedBy === auth.user.id) return true; const { getDb } = require('./src/database'); const placer = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(placedBy); if (placer && placer.is_admin) return false; if (!socketRuntime) return false; return socketRuntime.getUserEffectiveLevel(placedBy) < socketRuntime.getUserEffectiveLevel(auth.user.id); } // POST /api/moderation/kick app.post('/api/moderation/kick', modLimiter, express.json({ limit: '16kb' }), (req, res) => { const auth = getModUser(req, 'kick_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const db = getDb(); const { userId, channelCode, reason } = req.body; if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); if (!channelCode || typeof channelCode !== 'string') return res.status(400).json({ error: 'channelCode required' }); const channel = db.prepare('SELECT id FROM channels WHERE code = ? AND is_dm = 0').get(channelCode); if (!channel) return res.status(404).json({ error: 'Channel not found' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (!modOutranks(auth, userId)) return res.status(403).json({ error: 'You can only kick people ranked below you' }); httpModAudit(auth, 'user_kick', userId, target.username, { channel: channelCode }); db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?').run(channel.id, userId); if (io) { const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) { s.emit('kicked', { channelCode, reason: safeReason }); s.leave(`channel:${channelCode}`); } } } socketRuntime?.rotatePrivateCodesAfterRemoval?.(channel.id); res.json({ success: true, message: `Kicked ${target.username}` }); }); // POST /api/moderation/ban app.post('/api/moderation/ban', modLimiter, express.json({ limit: '16kb' }), (req, res) => { const auth = getModUser(req, 'ban_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const db = getDb(); const { userId, reason } = req.body; if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username, is_admin FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_admin) return res.status(403).json({ error: 'Cannot ban an admin' }); if (!modOutranks(auth, userId)) return res.status(403).json({ error: 'You can only ban people ranked below you' }); // Banning again takes over the ban, and with it who may lift it. const priorBan = db.prepare('SELECT banned_by FROM bans WHERE user_id = ?').get(userId); if (priorBan && !modMayUndo(auth, priorBan.banned_by)) return res.status(403).json({ error: 'That ban was placed by an admin or by someone of equal or higher rank' }); const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; httpModAudit(auth, 'user_ban', userId, target.username, { reason: safeReason }); try { db.prepare('INSERT OR REPLACE INTO bans (user_id, banned_by, reason) VALUES (?, ?, ?)').run(userId, auth.user.id, safeReason); } catch (err) { return res.status(500).json({ error: 'Failed to ban user' }); } if (io) { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) { s.emit('banned', { reason: safeReason }); s.disconnect(true); } } } res.json({ success: true, message: `Banned ${target.username}` }); }); // POST /api/moderation/unban app.post('/api/moderation/unban', modLimiter, express.json({ limit: '16kb' }), (req, res) => { const auth = getModUser(req, 'ban_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const db = getDb(); const { userId } = req.body; if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const existingBan = db.prepare('SELECT banned_by FROM bans WHERE user_id = ?').get(userId); if (existingBan && !modMayUndo(auth, existingBan.banned_by)) { return res.status(403).json({ error: 'You can\'t undo a ban placed by an admin or by someone of equal or higher rank' }); } db.prepare('DELETE FROM bans WHERE user_id = ?').run(userId); const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); if (existingBan) httpModAudit(auth, 'user_unban', userId, target ? target.username : null, null); res.json({ success: true, message: `Unbanned ${target ? target.username : 'user'}` }); }); // POST /api/moderation/mute app.post('/api/moderation/mute', modLimiter, express.json({ limit: '16kb' }), (req, res) => { const auth = getModUser(req, 'mute_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const db = getDb(); const { userId, duration, reason } = req.body; if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (!modOutranks(auth, userId)) return res.status(403).json({ error: 'You can only mute people ranked below you' }); // A new timeout replaces the old one, and with it who may lift it. for (const { muted_by: by } of db.prepare("SELECT DISTINCT muted_by FROM mutes WHERE user_id = ? AND expires_at > datetime('now')").all(userId)) { if (!modMayUndo(auth, by)) return res.status(403).json({ error: 'That timeout was given by an admin or by someone of equal or higher rank' }); } httpModAudit(auth, 'user_mute', userId, target.username, { duration }); // Same ceiling as the app: 30 days. const durationMs = Number.isInteger(duration) && duration > 0 ? Math.min(duration, 43200) * 60 * 1000 : 10 * 60 * 1000; // SQLite's own time format, which every mute check compares against. const expiresAt = new Date(Date.now() + durationMs).toISOString().slice(0, 19).replace('T', ' '); const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId); db.prepare('INSERT INTO mutes (user_id, muted_by, reason, expires_at) VALUES (?, ?, ?, ?)').run(userId, auth.user.id, safeReason, expiresAt); if (io) { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) { s.emit('muted', { duration: Math.round(durationMs / 60000), reason: safeReason, expiresAt }); } } } res.json({ success: true, message: `Muted ${target.username} until ${expiresAt}` }); }); // POST /api/moderation/unmute app.post('/api/moderation/unmute', modLimiter, express.json({ limit: '16kb' }), (req, res) => { const auth = getModUser(req, 'mute_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const db = getDb(); const { userId } = req.body; if (!userId || !Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); if (!auth.isAdmin && userId === auth.user.id) return res.status(403).json({ error: 'You can\'t unmute yourself' }); for (const { muted_by: by } of db.prepare("SELECT DISTINCT muted_by FROM mutes WHERE user_id = ? AND expires_at > datetime('now')").all(userId)) { if (!modMayUndo(auth, by)) return res.status(403).json({ error: 'You can\'t undo a mute placed by an admin or by someone of equal or higher rank' }); } db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId); const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); httpModAudit(auth, 'user_unmute', userId, target ? target.username : null, null); res.json({ success: true, message: `Unmuted ${target ? target.username : 'user'}` }); }); // GET /api/moderation/bans: list all bans app.get('/api/moderation/bans', modLimiter, (req, res) => { const auth = getModUser(req, 'ban_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const bans = getDb().prepare(` SELECT b.id, b.user_id, COALESCE(u.display_name, u.username) as username, b.reason, b.created_at FROM bans b JOIN users u ON b.user_id = u.id ORDER BY b.created_at DESC `).all(); res.json({ bans }); }); // GET /api/moderation/mutes: list active mutes app.get('/api/moderation/mutes', modLimiter, (req, res) => { const auth = getModUser(req, 'mute_user'); if (auth.error) return res.status(auth.status).json({ error: auth.error }); const { getDb } = require('./src/database'); const mutes = getDb().prepare(` SELECT m.id, m.user_id, COALESCE(u.display_name, u.username) as username, m.reason, m.expires_at, m.created_at FROM mutes m JOIN users u ON m.user_id = u.id WHERE m.expires_at > datetime('now') ORDER BY m.created_at DESC `).all(); res.json({ mutes }); }); // ═══════════════════════════════════════════════════════════ // BOT SLASH COMMANDS API // ═══════════════════════════════════════════════════════════ // Helper: authenticate webhook bot by token function getWebhookByToken(token) { if (!token || typeof token !== 'string' || token.length !== 64) return null; const { getDb } = require('./src/database'); return getDb().prepare(` SELECT w.id, w.name, w.channel_id, w.callback_url, w.can_moderate, w.can_use_voice, w.created_by, c.code AS channel_code FROM webhooks w LEFT JOIN channels c ON c.id = w.channel_id WHERE w.token = ? AND w.is_active = 1 AND COALESCE(c.is_dm, 0) = 0 AND ${CREATOR_NOT_BANNED_SQL} `).get(token); } function getBotCurrentVoiceChannel(webhookId) { if (!socketRuntime) return null; const botUserId = -Number(webhookId); for (const [channelCode, users] of socketRuntime.state.voiceUsers) { const presence = users.get(botUserId); if (!presence?.isBot || !presence.socketId) continue; const socket = io.sockets.sockets.get(presence.socketId); if ( socket?.connected && socket.user?.isBot && Number(socket.user.webhookId) === Number(webhookId) ) { return channelCode; } } return null; } function resolveCurrentBotVoiceChannel(webhook, requestedCode) { const currentCode = getBotCurrentVoiceChannel(webhook.id); if (!currentCode) return null; const expectedCode = typeof requestedCode === 'string' && requestedCode.trim() ? requestedCode.trim() : currentCode; if (expectedCode !== currentCode) return null; const channel = canAccessVoiceChannel(db, webhook, currentCode); if (!channel || channel.voice_enabled === 0) return null; return currentCode; } function requireWebhookVoice(req, res, next) { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(401).json({ error: 'Invalid bot token' }); if (!webhook.can_use_voice) return res.status(403).json({ error: 'Bot voice permission is required' }); const channelCode = resolveCurrentBotVoiceChannel(webhook); if (!channelCode) { return res.status(409).json({ error: 'Bot must be connected to an accessible voice channel' }); } req.botWebhook = webhook; req.botVoiceChannelCode = channelCode; return next(); } // ═══════════════════════════════════════════════════════════ // BOT MODERATION REST API (#5397): webhook-token authenticated. // Each endpoint requires the bot's `can_moderate` flag to be enabled // by an admin via the Bot Manager. Mirrors /api/moderation/* but uses // webhook tokens instead of JWT bearer tokens so bots don't need a // user login. Audit log records the bot as actor. // ═══════════════════════════════════════════════════════════ function requireModBot(req, res) { const webhook = getWebhookByToken(req.params.token); if (!webhook) { res.status(404).json({ error: 'Webhook not found or inactive' }); return null; } if (!webhook.can_moderate) { res.status(403).json({ error: 'This bot does not have moderation permission' }); return null; } return webhook; } // A moderation bot acts with the rank of the person who made it, so it can // do no more than they could themselves: nobody at or above them, and no // undoing an admin's or a higher-ranked moderator's ban or mute. A bot with // no creator on record acts with no rank at all. // It also needs the creator to hold, today, the permission the route uses: // a bot keeps no power its maker has since lost. const BOT_MOD_DENIED = 'The person who made this bot no longer has this moderation permission'; function botModAuth(webhook, permission, channelId = null) { if (!webhook || !webhook.created_by || !permission) return null; const { getDb } = require('./src/database'); const creator = getDb().prepare('SELECT id, username, display_name, is_admin FROM users WHERE id = ?').get(webhook.created_by); if (!creator) return null; if (!creator.is_admin && !(socketRuntime && socketRuntime.userHasPermission(creator.id, permission, channelId))) return null; return { user: { id: creator.id, username: creator.username, displayName: creator.display_name || creator.username }, isAdmin: !!creator.is_admin, bot: webhook }; } function httpModAudit(auth, action, targetId, targetName, details) { try { socketRuntime?.logAudit?.({ actor: { id: auth.user.id, username: auth.user.username, displayName: auth.user.displayName || auth.user.username }, action, target_type: 'user', target_id: targetId, target_name: targetName || null, details: { ...(details || {}), via: auth.bot ? `bot ${auth.bot.name}` : 'api' }, }); } catch (err) { console.warn('[audit] HTTP moderation entry failed:', err.message); } } // POST /api/webhooks/:token/moderation/kick app.post('/api/webhooks/:token/moderation/kick', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const { getDb } = require('./src/database'); const db = getDb(); const { userId, channelCode, reason } = req.body || {}; if (!Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); if (!channelCode || typeof channelCode !== 'string') return res.status(400).json({ error: 'channelCode required' }); // Only from the bot's own channel. const channel = db.prepare('SELECT id FROM channels WHERE code = ? AND id = ? AND is_dm = 0').get(channelCode, webhook.channel_id); if (!channel) return res.status(404).json({ error: 'Channel not found' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username, is_admin FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_admin) return res.status(403).json({ error: 'Cannot kick an admin' }); const botAuth = botModAuth(webhook, 'kick_user', channel.id); if (!botAuth) return res.status(403).json({ error: BOT_MOD_DENIED }); if (!modOutranks(botAuth, userId)) return res.status(403).json({ error: 'This bot can only act on people ranked below the person who made it' }); httpModAudit(botAuth, 'user_kick', userId, target.username, { channel: channelCode }); db.prepare('DELETE FROM channel_members WHERE channel_id = ? AND user_id = ?').run(channel.id, userId); if (io) { const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) { s.emit('kicked', { channelCode, reason: safeReason }); s.leave(`channel:${channelCode}`); } } } socketRuntime?.rotatePrivateCodesAfterRemoval?.(channel.id); res.json({ success: true, message: `Kicked ${target.username}` }); }); // POST /api/webhooks/:token/moderation/ban app.post('/api/webhooks/:token/moderation/ban', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const { getDb } = require('./src/database'); const db = getDb(); const { userId, reason } = req.body || {}; if (!Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username, is_admin FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_admin) return res.status(403).json({ error: 'Cannot ban an admin' }); const botAuth = botModAuth(webhook, 'ban_user'); if (!botAuth) return res.status(403).json({ error: BOT_MOD_DENIED }); if (!modOutranks(botAuth, userId)) return res.status(403).json({ error: 'This bot can only act on people ranked below the person who made it' }); const priorBan = db.prepare('SELECT banned_by FROM bans WHERE user_id = ?').get(userId); if (priorBan && !modMayUndo(botAuth, priorBan.banned_by)) return res.status(403).json({ error: 'That ban was placed by someone of equal or higher rank' }); const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; httpModAudit(botAuth, 'user_ban', userId, target.username, { reason: safeReason }); try { db.prepare('INSERT OR REPLACE INTO bans (user_id, banned_by, reason) VALUES (?, ?, ?)').run(userId, webhook.created_by || null, safeReason); } catch { return res.status(500).json({ error: 'Failed to ban user' }); } if (io) { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) { s.emit('banned', { reason: safeReason }); s.disconnect(true); } } } res.json({ success: true, message: `Banned ${target.username}` }); }); // POST /api/webhooks/:token/moderation/unban app.post('/api/webhooks/:token/moderation/unban', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const { getDb } = require('./src/database'); const db = getDb(); const { userId } = req.body || {}; if (!Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const botAuth = botModAuth(webhook, 'ban_user'); if (!botAuth) return res.status(403).json({ error: BOT_MOD_DENIED }); const existingBan = db.prepare('SELECT banned_by FROM bans WHERE user_id = ?').get(userId); if (existingBan && !modMayUndo(botAuth, existingBan.banned_by)) { return res.status(403).json({ error: 'You can\'t undo a ban placed by an admin or by someone of equal or higher rank' }); } if (existingBan) httpModAudit(botAuth, 'user_unban', userId, null, null); db.prepare('DELETE FROM bans WHERE user_id = ?').run(userId); const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); res.json({ success: true, message: `Unbanned ${target ? target.username : 'user'}` }); }); // POST /api/webhooks/:token/moderation/mute, body: { userId, duration (minutes), reason } app.post('/api/webhooks/:token/moderation/mute', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const { getDb } = require('./src/database'); const db = getDb(); const { userId, duration, reason } = req.body || {}; if (!Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const target = db.prepare('SELECT id, COALESCE(display_name, username) as username, is_admin FROM users WHERE id = ?').get(userId); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.is_admin) return res.status(403).json({ error: 'Cannot mute an admin' }); const botAuth = botModAuth(webhook, 'mute_user'); if (!botAuth) return res.status(403).json({ error: BOT_MOD_DENIED }); if (!modOutranks(botAuth, userId)) return res.status(403).json({ error: 'This bot can only act on people ranked below the person who made it' }); for (const { muted_by: by } of db.prepare("SELECT DISTINCT muted_by FROM mutes WHERE user_id = ? AND expires_at > datetime('now')").all(userId)) { if (!modMayUndo(botAuth, by)) return res.status(403).json({ error: 'That timeout was given by someone of equal or higher rank' }); } httpModAudit(botAuth, 'user_mute', userId, target.username, { duration }); // Same ceiling as the app: 30 days. const durationMs = Number.isInteger(duration) && duration > 0 ? Math.min(duration, 43200) * 60 * 1000 : 10 * 60 * 1000; // SQLite's own time format, which every mute check compares against. const expiresAt = new Date(Date.now() + durationMs).toISOString().slice(0, 19).replace('T', ' '); const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : ''; db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId); db.prepare('INSERT INTO mutes (user_id, muted_by, reason, expires_at) VALUES (?, ?, ?, ?)').run(userId, webhook.created_by || null, safeReason, expiresAt); if (io) { for (const [, s] of io.sockets.sockets) { if (s.user && s.user.id === userId) s.emit('muted', { reason: safeReason, expiresAt }); } } res.json({ success: true, message: `Muted ${target.username} until ${expiresAt}` }); }); // POST /api/webhooks/:token/moderation/unmute app.post('/api/webhooks/:token/moderation/unmute', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = requireModBot(req, res); if (!webhook) return; const { getDb } = require('./src/database'); const db = getDb(); const { userId } = req.body || {}; if (!Number.isInteger(userId)) return res.status(400).json({ error: 'userId required (integer)' }); const botAuth = botModAuth(webhook, 'mute_user'); if (!botAuth) return res.status(403).json({ error: BOT_MOD_DENIED }); for (const { muted_by: by } of db.prepare("SELECT DISTINCT muted_by FROM mutes WHERE user_id = ? AND expires_at > datetime('now')").all(userId)) { if (!modMayUndo(botAuth, by)) return res.status(403).json({ error: 'You can\'t undo a timeout given by an admin or by someone of equal or higher rank' }); } httpModAudit(botAuth, 'user_unmute', userId, null, null); db.prepare('DELETE FROM mutes WHERE user_id = ?').run(userId); const target = db.prepare('SELECT COALESCE(display_name, username) as username FROM users WHERE id = ?').get(userId); res.json({ success: true, message: `Unmuted ${target ? target.username : 'user'}` }); }); // GET /api/webhooks/:token/commands: list registered commands app.get('/api/webhooks/:token/commands', webhookLimiter, (req, res) => { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); const { getDb } = require('./src/database'); const rows = getDb().prepare('SELECT id, command, description, subcommands_json FROM bot_commands WHERE webhook_id = ?').all(webhook.id); const commands = rows.map(r => { let subcommands = []; if (r.subcommands_json) { try { const parsed = JSON.parse(r.subcommands_json); if (Array.isArray(parsed)) subcommands = parsed; } catch { /* ignore malformed historic values */ } } return { id: r.id, command: r.command, description: r.description, subcommands }; }); res.json({ commands }); }); // POST /api/webhooks/:token/commands: register a command app.post('/api/webhooks/:token/commands', webhookLimiter, express.json({ limit: '16kb' }), (req, res) => { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); if (!webhook.callback_url) return res.status(400).json({ error: 'Webhook must have a callback_url to register commands' }); const { command, description, subcommands } = req.body; if (!command || typeof command !== 'string') return res.status(400).json({ error: 'command required (string)' }); const cmd = command.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32); if (!cmd) return res.status(400).json({ error: 'Invalid command name' }); // Reject built-in command names const builtIn = ['shrug','tableflip','unflip','lenny','disapprove','bbs','boobs','butt','brb','afk','me','spoiler','tts','flip','roll','hug','wave','play','gif','poll']; if (builtIn.includes(cmd)) return res.status(409).json({ error: `/${cmd} is a built-in command` }); const desc = typeof description === 'string' ? description.trim().slice(0, 100) : ''; let cleanSubs = []; if (Array.isArray(subcommands)) { if (subcommands.length > 25) { return res.status(400).json({ error: 'subcommands can contain at most 25 items' }); } cleanSubs = subcommands .map(sc => { if (!sc || typeof sc !== 'object') return null; const name = String(sc.name || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32); if (!name) return null; const description = typeof sc.description === 'string' ? sc.description.trim().slice(0, 100) : ''; return { name, description }; }) .filter(Boolean); const seen = new Set(); cleanSubs = cleanSubs.filter(sc => { if (seen.has(sc.name)) return false; seen.add(sc.name); return true; }); } const subcommandsJson = cleanSubs.length ? JSON.stringify(cleanSubs) : null; const { getDb } = require('./src/database'); try { getDb().prepare('INSERT OR REPLACE INTO bot_commands (webhook_id, command, description, subcommands_json) VALUES (?, ?, ?, ?)').run(webhook.id, cmd, desc, subcommandsJson); res.json({ success: true, command: cmd, description: desc, subcommands: cleanSubs }); } catch (err) { res.status(500).json({ error: 'Failed to register command' }); } }); // DELETE /api/webhooks/:token/commands/:command: unregister a command app.delete('/api/webhooks/:token/commands/:command', webhookLimiter, (req, res) => { const webhook = getWebhookByToken(req.params.token); if (!webhook) return res.status(404).json({ error: 'Webhook not found or inactive' }); const cmd = (req.params.command || '').toLowerCase().replace(/[^a-z0-9]/g, ''); if (!cmd) return res.status(400).json({ error: 'Invalid command name' }); const { getDb } = require('./src/database'); const result = getDb().prepare('DELETE FROM bot_commands WHERE webhook_id = ? AND command = ?').run(webhook.id, cmd); if (result.changes === 0) return res.status(404).json({ error: 'Command not found' }); res.json({ success: true }); }); // GET /api/bot-commands: list all registered bot commands (for client autocomplete) // Signed in only, and only bots in channels the caller can see: open to // anyone, it listed the invite code of every channel with a bot command. app.get('/api/bot-commands', (req, res) => { const token = req.headers.authorization?.split(' ')[1]; const user = token ? verifyToken(token) : null; if (!user) return res.status(401).json({ error: 'Unauthorized' }); const { getDb } = require('./src/database'); const isAdmin = verifyAdminFromDb(user); const rows = getDb().prepare(` SELECT bc.command, bc.description, bc.subcommands_json, w.name as bot_name, c.code as channel_code, c.id AS channel_id, c.role_gate FROM bot_commands bc JOIN webhooks w ON bc.webhook_id = w.id JOIN channels c ON c.id = w.channel_id JOIN channel_members cm ON cm.channel_id = c.id AND cm.user_id = ? WHERE w.is_active = 1 AND ${CREATOR_NOT_BANNED_SQL} `).all(user.id).filter(r => isAdmin || !socketRuntime || socketRuntime.roleGateAllows(user.id, { id: r.channel_id, role_gate: r.role_gate })); const commands = []; for (const row of rows) { let subcommands = []; if (row.subcommands_json) { try { const parsed = JSON.parse(row.subcommands_json); if (Array.isArray(parsed)) subcommands = parsed; } catch { /* ignore malformed historic values */ } } if (subcommands.length) { for (const sc of subcommands) { const subName = String(sc?.name || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 32); if (!subName) continue; commands.push({ command: `${row.command} ${subName}`, description: typeof sc.description === 'string' && sc.description.trim() ? sc.description.trim() : (row.description || 'Bot command'), bot_name: row.bot_name || 'Bot', channel_code: row.channel_code || null }); } continue; } commands.push({ command: row.command, description: row.description || '', bot_name: row.bot_name || 'Bot', channel_code: row.channel_code || null }); } res.json({ commands }); }); // ═══════════════════════════════════════════════════════════ // DISCORD IMPORT: upload, preview, execute // ═══════════════════════════════════════════════════════════ const os = require('os'); // Multer instance for import uploads (ZIP/JSON up to 500 MB) const importUpload = multer({ storage: multer.diskStorage({ destination: os.tmpdir(), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `haven-import-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`); } }), limits: { fileSize: 500 * 1024 * 1024, fields: 20, fieldSize: 16 * 1024 }, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (ext === '.json' || ext === '.zip') cb(null, true); else cb(new Error('Only .json and .zip files are accepted')); } }); // ── Discord import: src/routes/import.js ── require('./src/routes/import')({ uploadDiskGuard, app, verifyAdminFromDb, uploadLimiter, importUpload }); // ── Server templates: src/routes/serverTemplate.js ── require('./src/routes/serverTemplate')({ app, verifyAdminFromDb, uploadLimiter, uploadDiskGuard, THEMES_DIR, late }); // Create HTTP or HTTPS server let server; // sslCert, sslKey, forceHttp and useSSL are resolved up with the security // headers, which have to know whether this process serves TLS. if (forceHttp) { console.log('⚡ FORCE_HTTP=true, running plain HTTP (reverse proxy mode)'); } if (useSSL) { try { const sslOptions = { cert: fs.readFileSync(sslCert), key: fs.readFileSync(sslKey) }; server = createHttpsServer(sslOptions, app); console.log('🔒 HTTPS enabled'); try { const { inspectCertChain, certAdvice } = require('./src/certCheck'); const status = inspectCertChain(sslOptions.cert); tlsIssue = status.issue; const advice = certAdvice(status, sslCert); if (advice) console.warn(`⚠️ TLS certificate problem: ${advice}`); } catch (err) { console.warn('⚠️ Could not check the TLS certificate chain:', err.message); } // Also start an HTTP server that redirects to HTTPS (hardened) const httpRedirect = express(); httpRedirect.disable('x-powered-by'); // Rate limit redirect server to prevent abuse const redirectHits = new Map(); httpRedirect.use((req, res, next) => { const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); if (!redirectHits.has(ip)) redirectHits.set(ip, []); const stamps = redirectHits.get(ip).filter(t => now - t < 60000); redirectHits.set(ip, stamps); if (stamps.length > 60) return res.status(429).end('Rate limited'); stamps.push(now); next(); }); setInterval(() => { const now = Date.now(); for (const [ip, t] of redirectHits) { const f = t.filter(x => now - x < 60000); if (!f.length) redirectHits.delete(ip); else redirectHits.set(ip, f); } }, 5 * 60 * 1000); // Only redirect to our own host (prevent open redirect) const safePort = parseInt(process.env.PORT || 3000); httpRedirect.all('*', (req, res) => { // Sanitize: only allow path portion, strip host manipulation const safePath = (req.url || '/').replace(/[\r\n]/g, ''); const host = (req.headers.host || `localhost:${safePort}`).replace(/:\d+$/, '') + ':' + safePort; res.redirect(301, `https://${host}${safePath}`); }); const HTTP_REDIRECT_PORT = safePort + 1; // 3001 const httpRedirectServer = createServer(httpRedirect); // Timeout to prevent Slowloris on redirect server httpRedirectServer.headersTimeout = 5000; httpRedirectServer.requestTimeout = 5000; // The redirect listener is a nicety: if its port is taken (or // binding it needs elevation), warn and carry on with HTTPS alone // rather than letting the bind error become an uncaught exception. httpRedirectServer.on('error', (err) => { console.warn(`⚠️ HTTP→HTTPS redirect server could not bind port ${HTTP_REDIRECT_PORT} (${(err && err.code) || err}). HTTPS continues on port ${safePort}.`); }); httpRedirectServer.listen(HTTP_REDIRECT_PORT, process.env.HOST || '0.0.0.0', () => { console.log(`↪️ HTTP redirect running on port ${HTTP_REDIRECT_PORT} → HTTPS`); }); } catch (err) { console.error('Failed to load SSL certs, falling back to HTTP:', err.message); if (err.code === 'EACCES') console.error(` Haven cannot read ${err.path || 'the certificate files'} as this user. Certbot keeps /etc/letsencrypt/live and /etc/letsencrypt/archive readable by root only: copy fullchain.pem and privkey.pem somewhere Haven can read (a certbot deploy hook keeps them fresh), or grant this user read access.`); server = createServer(app); } } else { server = createServer(app); console.log('⚠️ Running HTTP: voice chat requires HTTPS for remote connections'); } // Socket.IO: locked down const io = new Server(server, { cors: { origin: false, // same-origin only, no cross-site connections }, maxHttpBufferSize: 64 * 1024, // 64KB max per message (was 1MB) pingTimeout: 60000, pingInterval: 25000, connectTimeout: 10000, }); // Initialize const db = initDatabase(); // (#5335) Seed starter stickers now that the DB is ready. // Download / refresh the Unicode emoji list (non-blocking, best-effort). // Opt-in: off unless the admin enables it or UNICODE_EMOJI_AUTO_UPDATE forces it. { const emoji = require('./src/emoji'); const row = db.prepare("SELECT value FROM server_settings WHERE key = 'unicode_emoji_auto_update'").get(); emoji.ensureEmojiData(emoji.autoUpdateEnabled(row?.value)); } // Load the admin-configured Referrer-Policy into the in-memory cache. try { const rp = db.prepare("SELECT value FROM server_settings WHERE key = 'referrer_policy'").get()?.value; if (rp && VALID_REFERRER_POLICIES.includes(rp)) currentReferrerPolicy = rp; } catch (err) { console.warn(`[security] Could not load the saved Referrer-Policy, using ${DEFAULT_REFERRER_POLICY}:`, err.message); } // ── Admin password reset (one-time, from .env) ─────────── // Set ADMIN_RESET_PASSWORD in .env, restart, and it resets the admin's password. // The variable is removed from .env automatically after use. if (process.env.ADMIN_RESET_PASSWORD) { const bcryptSync = require('bcryptjs'); const adminName = (process.env.ADMIN_USERNAME || 'admin').toLowerCase(); const adminUser = db.prepare('SELECT id, username FROM users WHERE LOWER(username) = ?').get(adminName); if (adminUser) { const newHash = bcryptSync.hashSync(process.env.ADMIN_RESET_PASSWORD, 12); const newPwv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(adminUser.id)?.password_version || 1) + 1; db.prepare('UPDATE users SET password_hash = ?, password_version = ?, is_admin = 1 WHERE id = ?').run(newHash, newPwv, adminUser.id); db.prepare('DELETE FROM bans WHERE user_id = ?').run(adminUser.id); db.prepare('DELETE FROM mutes WHERE user_id = ?').run(adminUser.id); console.log(`🔑 Admin password reset for "${adminUser.username}" via ADMIN_RESET_PASSWORD`); // Remove the variable from .env so it doesn't re-run on next restart try { let envContent = fs.readFileSync(ENV_PATH, 'utf-8'); envContent = envContent.replace(/^ADMIN_RESET_PASSWORD=.*$/m, '').replace(/\n{3,}/g, '\n\n'); fs.writeFileSync(ENV_PATH, envContent); console.log(' Removed ADMIN_RESET_PASSWORD from .env (one-time use)'); } catch (err) { // Left in place it would reset the admin password again on every restart. console.error(` Could not remove ADMIN_RESET_PASSWORD from ${ENV_PATH}; delete that line by hand:`, err.message); } } else { console.warn(`⚠️ ADMIN_RESET_PASSWORD set but no user "${adminName}" found, skipping`); } delete process.env.ADMIN_RESET_PASSWORD; } // Load the admin FCM toggle (Settings → Security → FCM Privacy) into memory // before initFcm, so both the startup log line and isFcmEnabled() reflect it // without a per-message database read. Default on. try { const fe = db.prepare("SELECT value FROM server_settings WHERE key = 'fcm_enabled'").get()?.value; setFcmAdminEnabled(fe !== 'false'); } catch (err) { // Fail closed: if the admin turned FCM off for privacy, an unreadable // setting must not quietly turn it back on. setFcmAdminEnabled(false); console.error('[fcm] Could not read the FCM Privacy setting, leaving FCM off:', err.message); } initFcm(DATA_DIR); // Clean photos and videos uploaded before the privacy pass existed. privacyPass.start(db); // How far a video's cleaning has got, for the uploader's progress bar. privacyPass.setProgressEmitter((req, percent) => { const uploadId = String(req.headers['x-upload-id'] || '').slice(0, 64); const user = uploadId && verifyToken(req.headers.authorization?.split(' ')[1] || ''); if (!user) return; for (const [, s] of io.sockets.sockets) if (s.user?.id === user.id) s.emit('upload-processing', { uploadId, percent }); }); app.set('io', io); // expose to auth routes (session invalidation on password change) botAudioManager = new BotAudioManager(io, BOT_AUDIO_DIR); socketRuntime = setupSocketHandlers(io, db, { invalidateIpBanCache, // Share the cached ban matcher so the socket gate and the HTTP gate agree // on both normalization and CIDR handling, and the socket path stops // querying SQLite on every single connection. (v3.42.0) isIpBanned, // Per-member upload totals for the All Members list. Lives here because the // uploads directory and the walk that reads it are the HTTP layer's. (#5521) getUploadUsage, botAudioManager, // Keep the Referrer-Policy cache in sync when an admin changes it. onReferrerPolicyChange: (v) => { if (VALID_REFERRER_POLICIES.includes(v)) currentReferrerPolicy = v; } }); activityRef.engine = socketRuntime.activity; require('./src/httpAudit').setAuditSink(socketRuntime.logAudit); require('./src/selfDestruct').start({ db, io, UPLOAD_PATH_RE, moveUploadToDeleted: storage.softDelete, removeUpload: storage.remove }); // ── Ferry: Haven <-> Discord bridge ───────────────────── // Started after the socket layer so an inbound Discord message always has a // live io to broadcast on. Inserting the message is done here rather than // inside ferry.js so the bridge reuses the exact same row shape and event // payload as the existing bot webhook endpoint above, and Discord messages // render in every client with no client-side changes at all. initFerry({ db, io, sanitizeText, // A Discord forum post arrives through here too, with a title and tags, and // becomes a forum topic. insertHavenMessage: ({ channelId, channelCode, username, avatarUrl, content, title = null, tags = null, discordMessageId = null, replyTo = null }) => { // Topic titles get the same clean-up as titles typed in Haven. title = typeof title === 'string' && title ? (sanitizeText(title) || null) : null; // The Discord id is kept (#5773), so importing the same history later // skips what Ferry already brought over instead of adding it twice. const discordId = /^\d{5,25}$/.test(String(discordMessageId || '')) ? String(discordMessageId) : null; if (discordId) { const already = db.prepare('SELECT id FROM messages WHERE channel_id = ? AND discord_message_id = ?').get(channelId, discordId); if (already) return already.id; } try { const tagList = Array.isArray(tags) && tags.length ? tags : null; // A Discord reply to a message Ferry carried answers that message here // too (src/ferryReplies.js), in the same channel only. const replyRow = Number.isInteger(replyTo) ? db.prepare(` SELECT m.id, m.content, m.user_id, m.is_webhook, m.webhook_username, m.imported_from, m.persona_username, COALESCE(u.display_name, u.username, '[Deleted User]') AS username FROM messages m LEFT JOIN users u ON m.user_id = u.id WHERE m.id = ? AND m.channel_id = ? `).get(replyTo, channelId) : null; const result = db.prepare( 'INSERT INTO messages (channel_id, user_id, content, is_webhook, webhook_username, webhook_avatar, title, tags, discord_message_id, reply_to) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?)' ).run(channelId, null, content, username, avatarUrl || null, title || null, tagList ? JSON.stringify(tagList) : null, discordId, replyRow ? replyRow.id : null); const replyContext = replyRow ? toReplyContext(replyRow) : null; io.to(`channel:${channelCode}`).emit('new-message', { channelCode, message: { id: result.lastInsertRowid, content, title: title || undefined, tags: tagList || undefined, created_at: new Date().toISOString(), username: `[BOT] ${username}`, user_id: null, avatar: avatarUrl || null, avatar_shape: 'square', reply_to: replyRow ? replyRow.id : null, replyContext, reactions: [], is_webhook: true, webhook_name: username, from_discord: true } }); // The Haven member it answers is told, on their phone too, the same as // for a reply from someone in Haven. if (replyRow && replyRow.user_id && socketRuntime?.sendPushNotifications) { const ch = db.prepare('SELECT name FROM channels WHERE id = ?').get(channelId); socketRuntime.sendPushNotifications(channelId, channelCode, ch ? ch.name : '', 0, username, content, { onlyUserIds: [replyRow.user_id], replyToUserId: replyRow.user_id }); } return result.lastInsertRowid; } catch (err) { console.error('Ferry could not store an inbound Discord message:', err.message); return null; } }, // Applied when someone edits a Discord message Ferry already relayed. Reuses // the same `message-edited` event a Haven edit emits, so every open client // updates the message in place instead of showing a stale copy. editHavenMessage: ({ havenMessageId, channelCode, content }) => { try { const info = db.prepare( "UPDATE messages SET content = ?, edited_at = CURRENT_TIMESTAMP WHERE id = ? AND is_webhook = 1 AND is_archived = 0" ).run(content, havenMessageId); if (!info.changes) return; io.to(`channel:${channelCode}`).emit('message-edited', { channelCode, messageId: havenMessageId, content, editedAt: new Date().toISOString() }); } catch (err) { console.error('Ferry could not apply a Discord edit:', err.message); } }, // A message inside a Discord forum post becomes a reply in its Haven topic. // Same events a Haven reply sends, so an open thread panel shows it and the // topic's reply count and bump follow. // The Haven message a Discord thread hangs from (#5773): the copy of the // message it was started from, or, for a thread started on its own (or // whose first message never came across), a line with its name. findOrCreateThreadRoot({ channelId, channelCode, discordThreadId, name, username, avatarUrl }) { const found = db.prepare( 'SELECT id FROM messages WHERE channel_id = ? AND discord_message_id = ? AND thread_id IS NULL' ).get(channelId, String(discordThreadId)); if (found) return found.id; return this.insertHavenMessage({ channelId, channelCode, username, avatarUrl, content: `🧵 ${sanitizeText(String(name || '').slice(0, 100)) || 'Thread'}`, discordMessageId: discordThreadId, }); }, insertHavenThreadReply: ({ channelId, channelCode, parentId, username, avatarUrl, content }) => { try { const result = db.prepare( 'INSERT INTO messages (channel_id, user_id, content, thread_id, is_webhook, webhook_username, webhook_avatar) VALUES (?, ?, ?, ?, 1, ?, ?)' ).run(channelId, null, content, parentId, username, avatarUrl || null); const createdAt = new Date().toISOString(); io.to(`channel:${channelCode}`).emit('new-thread-message', { channelCode, parentId, message: { id: result.lastInsertRowid, content, created_at: createdAt, // Marked like every relayed author in channel history, so a Discord // nickname cannot pass for a Haven member's name. The stored and // webhook_username copies stay bare, so the prefix is added once. username: `[BOT] ${username}`, user_id: null, avatar: avatarUrl || null, avatar_shape: 'square', reply_to: null, replyContext: null, reactions: [], edited_at: null, is_webhook: 1, webhook_username: username, webhook_avatar: avatarUrl || null, thread_id: parentId, from_discord: true }, followerIds: threadNotify.followerIds(db, parentId) }); const count = db.prepare('SELECT COUNT(*) AS n FROM messages WHERE thread_id = ?').get(parentId).n; // Participants are Haven accounts only, the same as for a Haven reply, // so a relayed Discord author never shows there under any name. const participants = db.prepare(` SELECT DISTINCT COALESCE(u.display_name, u.username) as username, u.avatar FROM messages m JOIN users u ON m.user_id = u.id WHERE m.thread_id = ? ORDER BY m.created_at DESC LIMIT 5 `).all(parentId); io.to(`channel:${channelCode}`).emit('thread-updated', { channelCode, parentId, thread: { count, lastReplyAt: createdAt, lastReplyId: result.lastInsertRowid, senderId: null, participants: participants.map(p => ({ username: p.username, avatar: p.avatar })) } }); return result.lastInsertRowid; } catch (err) { console.error('Ferry could not store a Discord forum reply:', err.message); return null; } }, // A Discord post was renamed, retagged, locked or unlocked. Reuses the // `topic-updated` event a Haven edit of the topic sends. Only the fields // given change. updateHavenTopic: ({ messageId, channelCode, title, tags, closed }) => { try { if (typeof title === 'string' && title) { const clean = sanitizeText(title); if (clean) db.prepare('UPDATE messages SET title = ? WHERE id = ?').run(clean, messageId); } if (Array.isArray(tags)) db.prepare('UPDATE messages SET tags = ? WHERE id = ?').run(tags.length ? JSON.stringify(tags) : null, messageId); if (typeof closed === 'boolean') db.prepare('UPDATE messages SET closed = ? WHERE id = ?').run(closed ? 1 : 0, messageId); const row = db.prepare('SELECT title, tags, closed, nsfw FROM messages WHERE id = ?').get(messageId); if (!row) return; let tagList = []; try { tagList = JSON.parse(row.tags || '[]'); } catch { tagList = []; } io.to(`channel:${channelCode}`).emit('topic-updated', { channelCode, messageId, title: row.title || null, tags: tagList, closed: !!row.closed, nsfw: !!row.nsfw }); } catch (err) { console.error('Ferry could not update a forum topic:', err.message); } } }); registerProcessCleanup(); // Close the Discord socket deliberately on shutdown. Without this a container // restart leaves Discord holding a session it will keep feeding for a minute. for (const sig of ['SIGINT', 'SIGTERM']) { process.on(sig, () => { try { stopFerry(); } catch { /* shutting down anyway; Discord drops the session on its own */ } }); } // ── Auto-cleanup, auto-backup, dynamic DNS and updates: src/routes/maintenance.js ── require('./src/routes/maintenance')({ app, UPLOAD_PATH_RE, isSafeUploadRelPath, moveUploadToDeleted: storage.softDelete, verifyAdminFromDb, buildBackupFile, db, late }); // ── Catch-all: 404 ────────────────────────────────────── // Must be registered AFTER every app.get/post/etc. handler. Express // matches in registration order, so anything below this never runs. app.use((req, res) => { res.status(404).json({ error: 'Not found' }); }); // ── Global error handler (never leak stack traces) ────── app.use((err, req, res, _next) => { console.error('Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; const protocol = useSSL ? 'https' : 'http'; // ── Crash log helper ───────────────────────────────────── // Write crash events to a file so they survive even when stdout // is not captured (common on systemd-less Pi setups, screen // sessions that were closed, etc.). const CRASH_LOG = path.join(DATA_DIR, 'crash.log'); const MAX_CRASH_LOG_BYTES = (() => { const parsed = parseInt(process.env.HAVEN_CRASH_LOG_MAX_MB || '64', 10); const mb = Number.isFinite(parsed) && parsed > 0 ? parsed : 64; return mb * 1024 * 1024; })(); let _inLogCrash = false; let _consolePipeBroken = false; function isBrokenPipeError(err) { if (!err) return false; if (err && err.code === 'EPIPE') return true; const msg = String(err.message || err || ''); return /\bEPIPE\b/i.test(msg) || /broken pipe/i.test(msg); } function rotateCrashLogIfNeeded() { try { const stat = fs.statSync(CRASH_LOG); if (stat.size < MAX_CRASH_LOG_BYTES) return; const rotated = `${CRASH_LOG}.1`; try { fs.unlinkSync(rotated); } catch { /* no older rotated log yet */ } try { fs.renameSync(CRASH_LOG, rotated); } catch { fs.truncateSync(CRASH_LOG, 0); } } catch { // File may not exist yet. } } function logCrash(label, detail) { if (isBrokenPipeError(detail)) { _consolePipeBroken = true; return; } if (_inLogCrash) return; _inLogCrash = true; const ts = new Date().toISOString(); try { const mem = process.memoryUsage(); const line = `[${ts}] ${label}: ${detail instanceof Error ? detail.stack : detail}\n` + ` RSS=${Math.round(mem.rss / 1048576)}MB Heap=${Math.round(mem.heapUsed / 1048576)}/${Math.round(mem.heapTotal / 1048576)}MB\n`; if (!_consolePipeBroken) { try { console.error(`⚠️ ${label}:`, detail); } catch (e) { if (isBrokenPipeError(e)) _consolePipeBroken = true; } } try { rotateCrashLogIfNeeded(); fs.appendFileSync(CRASH_LOG, line); } catch { // disk full / read-only } } finally { _inLogCrash = false; } } // ── Global crash prevention ────────────────────────────── // Prevent the entire server from dying due to an uncaught exception // in a socket handler or background task. Log the error so it // can be debugged, but keep the process alive. process.on('uncaughtException', (err) => { if (isBrokenPipeError(err)) { _consolePipeBroken = true; return; } logCrash('Uncaught exception (server kept alive)', err); }); process.on('unhandledRejection', (reason) => { if (isBrokenPipeError(reason)) { _consolePipeBroken = true; return; } logCrash('Unhandled promise rejection (server kept alive)', reason); }); // ── Process exit logging ───────────────────────────────── // Catches ALL exits, including native crashes and V8 OOM. // The 'exit' event fires even for abort() / SIGSEGV on some // Node versions. We also log SIGABRT (V8 OOM fires this). process.on('exit', (code) => { if (code !== 0) { const ts = new Date().toISOString(); const line = `[${ts}] Process exited with code ${code}\n`; try { rotateCrashLogIfNeeded(); fs.appendFileSync(CRASH_LOG, line); } catch { /* process is exiting; nowhere left to report a log write failure */ } } }); // ── Event loop lag monitor ─────────────────────────────── // Detects when the event loop is blocked (heavy sync SQLite ops // or native module work). Logs a warning when lag exceeds 500ms // so we can correlate with crashes on low-power hardware. let _lastTick = Date.now(); setInterval(() => { const now = Date.now(); const lag = now - _lastTick - 2000; // expected interval is 2s if (lag > 500) { logCrash('Event loop lag', `${lag}ms (event loop was blocked)`); } _lastTick = now; }, 2000).unref(); // ── Memory watchdog ────────────────────────────────────── // Periodically log memory usage and nudge GC when heap is getting large. // This helps prevent the Oilpan "large allocation" OOM in Haven Desktop // where the server runs alongside Electron. // // Auto-detects system RAM so Raspberry Pi (1-4 GB) gets a lower // threshold than a 32 GB desktop. Fallback: 350 MB. const MEM_WARN_MB = (() => { try { const os = require('os'); const totalMB = Math.round(os.totalmem() / 1048576); // Warn at ~40% of total RAM (aggressive for low-RAM devices) const threshold = Math.round(totalMB * 0.4); // Clamp between 150 MB (Pi Zero) and 500 MB (big box) return Math.max(150, Math.min(500, threshold)); } catch { return 350; } })(); setInterval(() => { const mem = process.memoryUsage(); const heapMB = Math.round(mem.heapUsed / 1048576); const rssMB = Math.round(mem.rss / 1048576); const extMB = Math.round((mem.external || 0) / 1048576); // Log if above warning threshold if (rssMB > MEM_WARN_MB) { logCrash('Memory high', `RSS: ${rssMB} MB, Heap: ${heapMB} MB, External: ${extMB} MB (threshold: ${MEM_WARN_MB} MB)`); // Nudge GC if --expose-gc was passed if (global.gc) { global.gc(); console.warn(' GC nudged'); } } }, 30000); // every 30 seconds // ── Anti-Slowloris: server-level timeouts ──────────────── // headersTimeout is the real slowloris defense (slow/incomplete headers). The // whole-request cap is generous because admin backup restores upload multi-GB // bodies that take minutes; the restore handler additionally clears its own // socket inactivity timeout while it stages the upload to disk (#5436). server.headersTimeout = 15000; // 15s to send all headers server.requestTimeout = 3600000; // 1h max to finish sending a request body (large restore uploads) server.keepAliveTimeout = 65000; // slightly above typical ALB/LB timeout server.timeout = 120000; // 2 min socket inactivity timeout (resets on I/O) // ── Fatal listen errors must be loud ───────────────────── // A failed bind (port already taken, no permission) surfaces as an // async 'error' event. Without this handler it falls through to the // global uncaughtException keep-alive below, which writes it to // crash.log and keeps a process alive that never got its socket. // To the user that is a silent crash on launch: no banner, no error, // no exit, and the stale port-holder makes launch scripts think the // server came up. Bind failures are fatal: say why, then exit. server.on('error', (err) => { if (err && (err.code === 'EADDRINUSE' || err.code === 'EACCES' || err.code === 'EADDRNOTAVAIL')) { const why = err.code === 'EADDRINUSE' ? `port ${PORT} is already in use. Is another Haven instance (or other app) running?` : err.code === 'EADDRNOTAVAIL' ? `this machine has no network interface with address ${HOST}; check HOST in your .env` : process.platform === 'win32' ? `Windows refused port ${PORT}. Ports below 1024 need an elevated prompt; otherwise the port is usually inside a reserved range held by Hyper-V/WSL (see: netsh interface ipv4 show excludedportrange protocol=tcp). Pick another PORT in your .env, or free the range with: net stop winnat && net start winnat` : `no permission to bind port ${PORT}. Ports below 1024 need root. Keep Haven on a port above 1024 (the default is 3000) and forward 443 to it on your router, or put a reverse proxy such as Caddy or nginx in front`; console.error(`\n❌ Haven could not start: ${why}`); console.error(err.code === 'EADDRINUSE' ? ' Stop the other process or change PORT in your .env, then start Haven again.' : ' Fix that or change PORT/HOST in your .env, then start Haven again.'); logCrash('Fatal listen error (exiting)', err); process.exit(1); } logCrash('HTTP server error (server kept alive)', err); }); function onListening() { // Print the machine's LAN address rather than a YOUR_IP placeholder, which // more than one self-hoster has read as a broken config. (#5572) const lanIps = []; try { for (const ifaces of Object.values(require('os').networkInterfaces())) { for (const i of ifaces || []) { if (i.family === 'IPv4' && !i.internal) lanIps.push(i.address); } } } catch { /* leave the placeholder */ } const networkLine = `${protocol}://${lanIps[0] || 'YOUR_IP'}:${PORT}`; // Every row is 42 columns inside the box, so the right edge lines up. const row = (label, value) => `║ ${label.padEnd(9)}${String(value).padEnd(31)}║`; console.log(` ╔══════════════════════════════════════════╗ ║ 🏠 HAVEN is running ║ ╠══════════════════════════════════════════╣ ${row('Name:', process.env.SERVER_NAME || 'Haven')} ${row('Local:', `${protocol}://localhost:${PORT}`)} ${row('Network:', networkLine)} ${row('Admin:', process.env.ADMIN_USERNAME || 'admin')} ╚══════════════════════════════════════════╝ `); if (lanIps.length > 1) { console.log(` Also reachable on: ${lanIps.slice(1).map(ip => `${protocol}://${ip}:${PORT}`).join(', ')}`); } // Tunnel is now started manually via the admin panel button (no auto-start) // Dynamic DNS auto-updater (kicks in only if DDNS_PROVIDER is set in .env) try { startDdns(); } catch (err) { console.warn('[ddns] failed to start:', err && err.message); } } // Storage has to answer before anything is served. One that cannot be used // stops the server here instead of leaving it up with no media; the explicit // exit matters, because the rejection handler above keeps the process alive. storage.prepare(db).then(() => { seedStarterStickers(); // catches and logs its own errors server.listen(PORT, HOST, onListening); storage.keepIndexed(db); }, (err) => { console.error(`\n❌ Haven could not start: ${err.message}`); logCrash('Storage unavailable (exiting)', err); process.exit(1); }); function gracefulShutdown(signal) { const ts = new Date().toISOString(); const line = `[${ts}] Graceful shutdown: ${signal}\n`; try { rotateCrashLogIfNeeded(); fs.appendFileSync(CRASH_LOG, line); } catch { /* crash log is a diagnostic extra; shutdown continues */ } console.log(`\n${signal === 'admin' ? 'Shut down from Settings' : `${signal} received`}, shutting down`); botAudioManager?.shutdown(); require('./src/mediaTools').stopExiftool(); io.close(); const finish = () => { // Closing the database writes out its journal, so the next start does // not have to replay it. try { db.close(); } catch (err) { console.warn('[shutdown] database close failed:', err.message); } process.exit(0); }; server.close(finish); // A browser holding a keep-alive connection open can keep server.close() // waiting; don't let that stall the shutdown. setTimeout(finish, 5000).unref(); } process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); // The same shutdown for the admin's button in Settings (src/routes/maintenance.js). late.shutdown = (reason) => { try { stopFerry(); } catch { /* shutting down anyway; Discord drops the session on its own */ } gracefulShutdown(reason); };