// ── 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 });
// 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 });
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 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;
function isSafeUploadRelPath(relPath) {
if (typeof relPath !== 'string' || !relPath) return false;
if (!/^((?!\.\.)(?!\.\/)(?!\/)[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(relPath)) return false;
const parts = relPath.split('/');
if (parts.some(p => !p || p === '.' || p === '..')) return false;
return true;
}
function moveUploadToDeleted(relPath, srcRoot = UPLOADS_DIR) {
if (!isSafeUploadRelPath(relPath)) return;
const src = path.join(srcRoot, relPath);
if (!fs.existsSync(src)) return;
let stat;
try {
stat = fs.statSync(src);
} catch {
return;
}
if (!stat.isFile()) return;
const dst = path.join(DELETED_ATTACHMENTS_DIR, relPath);
try {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.renameSync(src, dst);
// A rename keeps the upload's own timestamp. The retention window counts
// from the deletion, so stamp the file now.
try { const now = new Date(); fs.utimesSync(dst, now, now); } catch { /* purge falls back to the upload time */ }
} catch (err) {
// Already moved by another path is fine; anything else leaves the file
// live in uploads/ after its message was deleted, so say so.
if (err.code !== 'ENOENT') console.warn(`[uploads] Could not move ${relPath} to deleted-attachments:`, err.message);
}
}
// For files an admin chose not to keep: gone for good, never held for recovery.
function removeUpload(relPath) {
if (!isSafeUploadRelPath(relPath)) return;
try {
fs.unlinkSync(path.join(UPLOADS_DIR, relPath));
} catch (err) {
if (err.code !== 'ENOENT') console.warn(`[uploads] Could not remove ${relPath}:`, err.message);
}
}
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;
}
function relocateUnreferencedUploads(db, relPaths) {
const candidates = new Set(
Array.from(relPaths).filter(relPath => fs.existsSync(path.join(UPLOADS_DIR, 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;
}
const findOwnership = db.prepare(
'SELECT user_id, scope, created_at FROM upload_ownership WHERE rel_path = ?'
);
const latestDmMessageByUser = new Map(db.prepare(`
SELECT m.user_id, 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 IS NOT NULL
GROUP BY m.user_id
`).all().map(row => [row.user_id, row.referenced_at]));
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.
if (!ownership || ownership.scope !== 'channel') continue;
const latestDmMessage = latestDmMessageByUser.get(ownership.user_id);
if (latestDmMessage && latestDmMessage >= ownership.created_at) continue;
if (findProtectedFilenameReference.get(relPath, relPath, relPath)) continue;
moveUploadToDeleted(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;
}
// Walk the live uploads tree once and total it per owner. Reading sizes from
// disk rather than trusting the stored byte count means a deleted, purged, or
// moved-to-deleted-attachments file drops out on its own, with no delete hook to
// keep in sync, and no drift between the report and reality.
let _uploadUsageCache = null;
function getUploadUsage() {
if (_uploadUsageCache && Date.now() - _uploadUsageCache.at < 60_000) return _uploadUsageCache.data;
const sizes = new Map(); // relPath → bytes
const walk = (dir, rel) => {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
// Deleted attachments and temporary bot audio are not live member storage.
if (!rel && ['bot-audio', 'deleted-attachments'].includes(entry.name)) continue;
const sub = rel ? `${rel}/${entry.name}` : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { walk(full, sub); continue; }
try { sizes.set(sub, fs.statSync(full).size); } catch { /* vanished mid-walk */ }
}
};
walk(UPLOADS_DIR, '');
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)
}));
// ── Block access to internal upload folders ─────────────
// Files moved into deleted-attachments are no longer part of any message and
// must stop being reachable, which is the entire point of moving them.
//
// A 404 mounted at the prefix does not achieve that. Express matches the mount
// against the raw path while express.static decodes before it resolves, so
// three shapes walked straight past the guard and served the file:
// /uploads/deleted%2Dattachments/x, /uploads//deleted-attachments/x, and
// /uploads/deleted-attachments%2Fx. Anyone who saw an attachment before it was
// deleted knows its filename, so deletion was not actually revoking access.
//
// Decode the path, resolve it against the uploads root, and check containment,
// so it is the real target on disk being judged rather than the spelling of
// the URL. Compared case-insensitively because NTFS is.
const UPLOAD_MEDIA_EXTS = new Set([
'.mp3', '.ogg', '.oga', '.wav', '.m4a', '.aac', '.flac', '.opus', '.weba',
'.mp4', '.webm', '.mov', '.m4v', '.ogv',
]);
const BLOCKED_UPLOAD_DIRS = ['deleted-attachments', 'bot-audio'].map(
dir => path.resolve(UPLOADS_DIR, dir).toLowerCase()
);
app.use('/uploads', (req, res, next) => {
let decoded;
try { decoded = decodeURIComponent(req.path); } catch { return res.status(400).end(); }
// Windows also answers to a folder's 8.3 short name (DELETE~1 for
// deleted-attachments), and ignores a trailing dot or space and anything
// after a colon (alternate streams). None of those appear in a real upload
// name, so a path with any of them is refused before it can slip past.
if (decoded.split(/[\\/]/).some(seg => seg.includes('~') || seg.includes(':') || /[. ]$/.test(seg))) {
return res.status(404).end();
}
// path.resolve treats a backslash as a separator on Windows and as an
// ordinary filename character on Linux, which is exactly right in both
// cases, so the raw decoded path goes in as-is.
const target = path.resolve(UPLOADS_DIR, '.' + decoded).toLowerCase();
for (const blocked of BLOCKED_UPLOAD_DIRS) {
if (target === blocked || target.startsWith(blocked + path.sep)) return res.status(404).end();
}
return next();
});
// ── Serve uploads from external data directory ──────────
app.use('/uploads', express.static(UPLOADS_DIR, {
dotfiles: 'deny',
maxAge: '7d', // 7 days: avatars & images rarely change; filenames include timestamps for uniqueness
immutable: true, // tells browser the file at this URL will never change (cache-busting via new filename)
etag: true,
lastModified: true,
setHeaders: (res, filePath) => {
// Force download for non-image files (prevents HTML/SVG execution in browser)
const ext = path.extname(filePath).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
// Allow cross-origin access for images (needed for server icon pulling).
// CORP override is required because helmet defaults to 'same-origin', which
// would otherwise block cross-origin
loads even with ACAO set.
// Vary: Origin prevents a non-CORS cached response from being reused for a
// CORS request (which is what causes the "No 'Access-Control-Allow-Origin'
// header is present" error on a cached image).
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Vary', 'Origin');
} else if (ext === '.svg') {
// SVG (issue #5309): renderable inline via
tag (browsers run SVG in
// "secure static mode": no scripts, no XHR), but direct navigation still
// gets attachment-disposition so opening the raw URL in a new tab can't
// execute the file. CSP doubles up on that: even if a future browser
// change allowed any external loads inside
-rendered SVG, this
// header forbids everything except inline styles (needed for fill/stroke).
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
res.setHeader('Vary', 'Origin');
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
} else {
res.setHeader('Content-Disposition', 'attachment');
// Anyone can upload a file, and one served as JavaScript (or HTML, CSS,
// XML) from Haven's own origin counts as 'self' in the CSP: a