Files
Haven/server.js
T
Matt 52c68bac2a Add a generic listening-presence webhook and a live profile card (#5523)
* Add Navidrome rich presence integration

Shows what a user is playing in Navidrome on their Haven profile, through the
Havidrome companion plugin posting to a reserved per-user webhook.

Users generate a revocable webhook token in Settings > Activity > Navidrome.
The webhook takes strict multipart: title and duration are required, and cover
bytes are magic byte checked and held in Haven's memory. The Haven server hands
those bytes to viewers' browsers from its own /api/activity/navidrome-cover
path, so Navidrome is never contacted and its address never leaves the plugin.
Presence runs through the existing activity slot so it stays online gated,
clears on disconnect, and auto expires at track end. The profile card gains a
live progress bar; pause freezes it, stop clears it.

* Update the open profile card live from presence

The profile card was a snapshot, so a track pausing, resuming, changing or
ending did nothing until it was reopened. It now re-renders its activity from
the online-users broadcasts the client already receives, so the bar freezes on
pause, resumes on play, swaps on a new track and clears on stop while the card
stays open. It rides the existing presence stream, no new traffic.

* Make the whole profile card live and stop it clipping off-screen

The open profile card only re-rendered its music activity from presence;
status (online/away/dnd/offline) and custom status text stayed frozen at
whatever they were when it opened. It now refreshes all three from the
online-users broadcasts the client already receives, so a user going away,
setting a status or dropping offline updates the card in place while it
stays open. Shared _statusDotClass/_statusLabel/_profileStatusTextHtml
helpers keep the initial render and the live refresh in sync, and the
custom-status line gets a stable slot so it can appear and clear. Rides
the existing presence stream, no new traffic.

Positioning measured the card with a hardcoded 320x400 guess, but its
width is a rem value and its height varies with content, so once font-size
or zoom differed from 16px the real card was larger than the guess and the
viewport clamp let it spill off the right edge (desktop app and web). It
now measures the real offsetWidth/offsetHeight before clamping.

* Expire a paused Navidrome track after 5 minutes idle

A paused track cleared its play-to-end timer and set no replacement, so
the slot lingered indefinitely with no further posts. Since presence is
gated on being online, the stale track stayed hidden while offline but
reappeared on reconnect. Arm a 5-minute idle timeout while paused instead;
every update resets it, so an actively-paused client stays shown.

* Rate-limit the Navidrome cover route

The cover-serving GET had no limiter while the webhook it pairs with did.
It's viewer-facing (every open profile card fetches it), so add a separate,
looser cap (120/min vs the webhook's 60/min) rather than reusing the
webhook limiter, keeping the two routes consistent without throttling
legitimate viewing.

* Make the music presence webhook generic, not Navidrome-only

The rich presence webhook was tied to Navidrome and a single companion
plugin, so Haven had a settings page that only worked with one source and
would point at nothing if that plugin went away.

It is now a source-agnostic Listening webhook. Any music player that can
run a plugin or a script posts a title, artist and duration (plus optional
album, position, cover art and a source label) to a personal URL. The
contract is documented in listening_api.md.

- Route is /api/webhooks/listening/:token, cover is
  /api/activity/listening-cover/:userId. Table navidrome_tokens is renamed
  to listening_tokens with a migration that keeps existing tokens.
- A track is identified by a server-side hash of title, artist and
  duration rather than a poster-supplied id, so nothing untrusted is used
  as an identifier.
- Expiry is a capped lease instead of trusting the reported duration: a
  playing track is held until it would end, up to 60 minutes, and long
  media stays visible by sending a heartbeat. A fake duration can no
  longer leave a status stuck.

Anyone still using the old Navidrome webhook needs to point their plugin
at the new /api/webhooks/listening URL.

* Scroll long track titles and artists instead of clipping them

A long title or artist was cut off with an ellipsis at whatever width
the viewer's profile card happened to be, so different people saw
different amounts of the same track. Past 30 characters the text now
scrolls in a seamless loop at a constant speed, gated on character
count rather than pixel width so it reads the same for everyone.
Shorter text is unchanged, and the scroll respects reduced-motion.

* Trim the redundant intro in the listening API doc
2026-09-01 20:50:37 -04:00

6057 lines
276 KiB
JavaScript

// ── Resolve data directory BEFORE loading .env ────────────
const { DATA_DIR, DB_PATH, ENV_PATH, CERTS_DIR, UPLOADS_DIR, DELETED_ATTACHMENTS_DIR } = require('./src/paths');
// ── 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 = other problems */ }
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);
}
}
if (!fs.existsSync(ENV_PATH)) {
const example = path.join(__dirname, '.env.example');
if (fs.existsSync(example)) {
fs.copyFileSync(example, ENV_PATH);
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');
}
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 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);
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);
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, sanitizeBorderTransform } = require('./src/socketHandlers');
const { initFerry, stopFerry } = require('./src/ferry');
const { canAccessVoiceChannel, getAccessibleVoiceChannels } = require('./src/botVoice');
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 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;
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);
} catch { /* file locked or already moved */ }
}
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 { /* table missing on a database that has not migrated yet */ }
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 — controls how many reverse-proxy hops to trust
// when reading the real client IP from X-Forwarded-For.
//
// TRUST_PROXY=1 (default) — trust the first hop (nginx/Traefik/Cloudflare)
// TRUST_PROXY=0 — direct exposure; do NOT trust XFF headers
// (prevents attackers from spoofing their IP to
// bypass the auth rate limiter)
// TRUST_PROXY=2 — two proxy hops, etc.
//
// Without this every user behind a reverse proxy shares the loopback IP in
// the auth rate limiter, causing innocent users to hit the limit on their
// very first login/register attempt.
const _trustProxy = process.env.TRUST_PROXY !== undefined
? (isNaN(Number(process.env.TRUST_PROXY)) ? process.env.TRUST_PROXY : Number(process.env.TRUST_PROXY))
: 1;
app.set('trust proxy', _trustProxy);
// ── 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 { _ipBanCache = { set: new Set(), cidrs: [], expires: Date.now() + 30000 }; }
}
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;
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.
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; }
}
function userHasPermission(userId, permission) {
if (!userId) return false;
try {
const { getDb } = require('./src/database');
const isAdmin = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(userId);
if (isAdmin && isAdmin.is_admin) return true;
const row = getDb().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) ────────────────────────────
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-eval'", "'wasm-unsafe-eval'", "blob:", "https://www.youtube.com", "https://w.soundcloud.com", "https://unpkg.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:", "https://unpkg.com"], // 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
...(process.env.FORCE_HTTP?.toLowerCase() === 'true' ? { upgradeInsecureRequests: null } : {}), // helmet 8.x auto-appends upgrade-insecure-requests; disable when FORCE_HTTP=true
}
},
crossOriginEmbedderPolicy: false, // needed for WebRTC
crossOriginOpenerPolicy: false, // needed for WebRTC
hsts: (process.env.FORCE_HTTP || '').toLowerCase() === 'true' ? false : { maxAge: 31536000, includeSubDomains: false }, // force HTTPS for 1 year (disabled when FORCE_HTTP=true)
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,
}));
// ── 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 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(); }
// 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 <img> 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 <img> 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 <img>-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');
}
}
}));
// ── 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 });
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;
const uploadStorage = multer.diskStorage({
destination: uploadDir,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
}
});
// Image-only upload — multer cap is generous; real limit enforced per-request from DB
const upload = multer({
storage: uploadStorage,
limits: { fileSize: 100 * 1024 * 1024 * 1024 }, // 100 GB ceiling — admin DB setting is the real limit
fileFilter: (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)'));
}
});
// General file upload — no MIME restrictions; safety enforced via
// Content-Disposition: attachment on non-image downloads (see /uploads handler)
const fileUpload = multer({
storage: uploadStorage,
limits: { fileSize: 100 * 1024 * 1024 * 1024 }, // 100 GB ceiling — admin DB setting is the real limit
});
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));
// ── 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(), (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 (!endpoint || !keys?.p256dh || !keys?.auth)
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);
})();
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' });
// 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')"
).all();
rows.forEach(r => { dbSettings[r.key] = r.value; });
} catch { /* DB not ready — fall back to env/defaults below */ }
// 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 });
});
// ── Avatar upload endpoint (saves to /uploads, updates DB) ──
app.post('/api/upload-avatar', 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' });
const { getDb } = require('./src/database');
const ban = getDb().prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (ban) return res.status(403).json({ error: 'Banned users cannot upload' });
upload.single('avatar')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
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: 'Avatar must be under 2 MB' });
}
// Validate file 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: 'File content does not match image type' });
}
} catch {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to validate file' });
}
// Force safe extension
const mimeToExt = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const safeExt = mimeToExt[req.file.mimetype];
if (!safeExt) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Invalid file type' });
}
const currentExt = path.extname(req.file.filename).toLowerCase();
let finalName = req.file.filename;
if (currentExt !== safeExt) {
finalName = req.file.filename.replace(/\.[^.]+$/, '') + safeExt;
const oldPath = req.file.path;
const newPath = path.join(uploadDir, finalName);
fs.renameSync(oldPath, newPath);
}
const avatarUrl = `/uploads/${finalName}`;
recordUploadOwnership(user.id, finalName, req.file.size, 'profile');
// Update the user's avatar in the database
try {
const db = getDb();
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(avatarUrl, user.id);
console.log(`[Avatar] ${user.username} uploaded avatar: ${avatarUrl}`);
} catch (dbErr) {
console.error('Avatar DB update error:', dbErr);
return res.status(500).json({ error: 'Failed to save avatar' });
}
res.json({ url: avatarUrl });
});
});
// ── Avatar remove endpoint ──
app.post('/api/remove-avatar', 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' });
try {
const { getDb } = require('./src/database');
getDb().prepare('UPDATE users SET avatar = NULL WHERE id = ?').run(user.id);
res.json({ ok: true });
} catch (err) {
console.error('Avatar remove error:', err);
res.status(500).json({ error: 'Failed to remove avatar' });
}
});
// ── Border upload endpoint (pfp overlay, mirrors avatar upload) ──
app.post('/api/upload-border', 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' });
const { getDb } = require('./src/database');
const ban = getDb().prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (ban) return res.status(403).json({ error: 'Banned users cannot upload' });
// Enforce upload_files permission (admin always allowed)
if (!verifyAdminFromDb(user)) {
const hasPerm = getDb().prepare(`
SELECT 1 FROM role_permissions rp
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = ? AND rp.permission = 'upload_files' AND rp.allowed = 1 LIMIT 1
`).get(user.id);
if (!hasPerm) return res.status(403).json({ error: 'You don\'t have permission to upload files' });
}
upload.single('border')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
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: 'Border must be under 2 MB' });
}
// Validate file 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: 'File content does not match image type' });
}
} catch {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to validate file' });
}
// Force safe extension
const mimeToExt = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const safeExt = mimeToExt[req.file.mimetype];
if (!safeExt) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Invalid file type' });
}
const currentExt = path.extname(req.file.filename).toLowerCase();
let finalName = req.file.filename;
if (currentExt !== safeExt) {
finalName = req.file.filename.replace(/\.[^.]+$/, '') + safeExt;
const oldPath = req.file.path;
const newPath = path.join(uploadDir, finalName);
fs.renameSync(oldPath, newPath);
}
const borderUrl = `/uploads/${finalName}`;
// Update the user's border in the database
try {
const db = getDb();
// A new image invalidates the old fit; clear it so no stale transform lingers.
db.prepare('UPDATE users SET border = ?, border_transform = NULL WHERE id = ?').run(borderUrl, user.id);
console.log(`[Border] ${user.username} uploaded border: ${borderUrl}`);
} catch (dbErr) {
console.error('Border DB update error:', dbErr);
return res.status(500).json({ error: 'Failed to save border' });
}
res.json({ url: borderUrl });
});
});
// ── Border remove endpoint ──
app.post('/api/remove-border', 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' });
try {
const { getDb } = require('./src/database');
getDb().prepare('UPDATE users SET border = NULL, border_transform = NULL WHERE id = ?').run(user.id);
res.json({ ok: true });
} catch (err) {
console.error('Border remove error:', err);
res.status(500).json({ error: 'Failed to remove border' });
}
});
// ── Border fit (op log) endpoint ──
// Stores the pfp-overlay transform as sanitized JSON, mirroring set-avatar-shape.
app.post('/api/set-border-transform', 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 clean = sanitizeBorderTransform(req.body.transform);
const toStore = (clean && clean.length) ? JSON.stringify(clean) : null;
try {
const { getDb } = require('./src/database');
getDb().prepare('UPDATE users SET border_transform = ? WHERE id = ?').run(toStore, user.id);
res.json({ transform: toStore ? clean : null });
} catch (err) {
console.error('Border transform error:', err);
res.status(500).json({ error: 'Failed to save border fit' });
}
});
// ── Avatar shape endpoint ──
app.post('/api/set-avatar-shape', 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 validShapes = ['circle', 'rounded', 'squircle', 'hex', 'diamond'];
const shape = validShapes.includes(req.body.shape) ? req.body.shape : 'circle';
try {
const { getDb } = require('./src/database');
getDb().prepare('UPDATE users SET avatar_shape = ? WHERE id = ?').run(shape, user.id);
res.json({ shape });
} catch (err) {
console.error('Avatar shape error:', err);
res.status(500).json({ error: 'Failed to save shape' });
}
});
// ── Animated-profile policy endpoint ──
// How this user's animated avatar/border play for everyone, mirroring set-avatar-shape.
app.post('/api/set-animate-profile', 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 valid = ['trigger', 'disabled'];
const mode = valid.includes(req.body.mode) ? req.body.mode : 'trigger';
try {
const { getDb } = require('./src/database');
getDb().prepare('UPDATE users SET animate_profile = ? WHERE id = ?').run(mode, user.id);
res.json({ mode });
} catch (err) {
console.error('Animate profile error:', err);
res.status(500).json({ error: 'Failed to save animation policy' });
}
});
// ── Webhook/Bot avatar upload endpoint ──
app.post('/api/upload-webhook-avatar', 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' });
// Admins or users with manage_webhooks permission can upload webhook avatars
const { getDb } = require('./src/database');
const dbUser = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(user.id);
if (!dbUser || (!dbUser.is_admin && !userHasPermission(user.id, 'manage_webhooks'))) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
upload.single('avatar')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// Validate file 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: 'File content does not match image type' });
}
} catch {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to validate file' });
}
const mimeToExt = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const safeExt = mimeToExt[req.file.mimetype];
if (!safeExt) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Invalid file type' });
}
const currentExt = path.extname(req.file.filename).toLowerCase();
let finalName = req.file.filename;
if (currentExt !== safeExt) {
finalName = req.file.filename.replace(/\.[^.]+$/, '') + safeExt;
fs.renameSync(req.file.path, path.join(uploadDir, finalName));
}
const avatarUrl = `/uploads/${finalName}`;
// Update the webhook's avatar in DB
const webhookId = parseInt(req.body?.webhookId || req.query?.webhookId);
if (!isNaN(webhookId)) {
try {
getDb().prepare('UPDATE webhooks SET avatar_url = ? WHERE id = ?').run(avatarUrl, webhookId);
} catch (dbErr) {
console.error('Webhook avatar DB error:', dbErr);
}
}
res.json({ url: avatarUrl });
});
});
// ── Personas (proxy feature) (#86, #5349) ─────────────────
// CRUD + avatar upload for per-user personas. Triggered in chat with
// "PersonaName: message" (handled by send-message socket handler).
app.get('/api/personas', (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 id, name, avatar, bio, created_at FROM user_personas WHERE user_id = ? ORDER BY name COLLATE NOCASE ASC'
).all(user.id);
res.json({ personas: rows });
} catch (err) {
console.error('GET /api/personas error:', err);
res.status(500).json({ error: 'Failed to load personas' });
}
});
const _validatePersonaName = (raw) => {
if (typeof raw !== 'string') return null;
const trimmed = raw.trim();
if (trimmed.length < 1 || trimmed.length > 32) return null;
// Disallow ":" and control/newline chars (the trigger uses "Name:")
if (/[\u0000-\u001F:\n\r]/.test(trimmed)) return null;
return trimmed;
};
app.post('/api/personas', 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 name = _validatePersonaName(req.body?.name);
if (!name) return res.status(400).json({ error: 'Persona name must be 1-32 chars and may not contain ":" or line breaks' });
const bio = typeof req.body?.bio === 'string' ? req.body.bio.slice(0, 190) : null;
const avatar = typeof req.body?.avatar === 'string' && req.body.avatar.startsWith('/uploads/')
? req.body.avatar : null;
try {
const { getDb } = require('./src/database');
const db = getDb();
// Cap personas per user to keep abuse / accidental spam in check.
const count = db.prepare('SELECT COUNT(*) as c FROM user_personas WHERE user_id = ?').get(user.id).c;
if (count >= 25) return res.status(400).json({ error: 'Persona limit reached (25 max)' });
// Block names that collide with real usernames to prevent impersonation.
const collision = db.prepare(
'SELECT id FROM users WHERE username = ? COLLATE NOCASE OR display_name = ? COLLATE NOCASE'
).get(name, name);
if (collision) return res.status(400).json({ error: 'That name is already taken by a real user' });
const result = db.prepare(
'INSERT INTO user_personas (user_id, name, avatar, bio) VALUES (?, ?, ?, ?)'
).run(user.id, name, avatar, bio);
const row = db.prepare(
'SELECT id, name, avatar, bio, created_at FROM user_personas WHERE id = ?'
).get(result.lastInsertRowid);
res.json({ persona: row });
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(400).json({ error: 'You already have a persona with that name' });
}
console.error('POST /api/personas error:', err);
res.status(500).json({ error: 'Failed to create persona' });
}
});
app.patch('/api/personas/:id', 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 id = parseInt(req.params.id);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Bad id' });
try {
const { getDb } = require('./src/database');
const db = getDb();
const persona = db.prepare('SELECT id FROM user_personas WHERE id = ? AND user_id = ?').get(id, user.id);
if (!persona) return res.status(404).json({ error: 'Persona not found' });
const updates = [];
const vals = [];
if (req.body?.name !== undefined) {
const name = _validatePersonaName(req.body.name);
if (!name) return res.status(400).json({ error: 'Persona name must be 1-32 chars and may not contain ":" or line breaks' });
const collision = db.prepare(
'SELECT id FROM users WHERE username = ? COLLATE NOCASE OR display_name = ? COLLATE NOCASE'
).get(name, name);
if (collision) return res.status(400).json({ error: 'That name is already taken by a real user' });
updates.push('name = ?'); vals.push(name);
}
if (req.body?.avatar !== undefined) {
const avatar = req.body.avatar === null ? null
: (typeof req.body.avatar === 'string' && req.body.avatar.startsWith('/uploads/') ? req.body.avatar : null);
updates.push('avatar = ?'); vals.push(avatar);
}
if (req.body?.bio !== undefined) {
const bio = req.body.bio === null ? null
: (typeof req.body.bio === 'string' ? req.body.bio.slice(0, 190) : null);
updates.push('bio = ?'); vals.push(bio);
}
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(id, user.id);
db.prepare(`UPDATE user_personas SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`).run(...vals);
const row = db.prepare(
'SELECT id, name, avatar, bio, created_at FROM user_personas WHERE id = ?'
).get(id);
res.json({ persona: row });
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(400).json({ error: 'You already have a persona with that name' });
}
console.error('PATCH /api/personas/:id error:', err);
res.status(500).json({ error: 'Failed to update persona' });
}
});
app.delete('/api/personas/:id', (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 id = parseInt(req.params.id);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Bad id' });
try {
const { getDb } = require('./src/database');
getDb().prepare('DELETE FROM user_personas WHERE id = ? AND user_id = ?').run(id, user.id);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/personas/:id error:', err);
res.status(500).json({ error: 'Failed to delete persona' });
}
});
// Persona avatar upload — same validation as user avatar (2 MB, magic-byte check)
app.post('/api/upload-persona-avatar', 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' });
const { getDb } = require('./src/database');
const ban = getDb().prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (ban) return res.status(403).json({ error: 'Banned users cannot upload' });
upload.single('avatar')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (req.file.size > 2 * 1024 * 1024) {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Avatar must be under 2 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);
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: 'File content does not match image type' });
}
} catch {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to validate file' });
}
const mimeToExt = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const safeExt = mimeToExt[req.file.mimetype];
if (!safeExt) {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Invalid file type' });
}
const currentExt = path.extname(req.file.filename).toLowerCase();
let finalName = req.file.filename;
if (currentExt !== safeExt) {
finalName = req.file.filename.replace(/\.[^.]+$/, '') + safeExt;
fs.renameSync(req.file.path, path.join(uploadDir, finalName));
}
const avatarUrl = `/uploads/${finalName}`;
recordUploadOwnership(user.id, finalName, req.file.size, 'profile');
// Optional: if a personaId is supplied, persist immediately (verifying ownership).
const personaId = parseInt(req.body?.personaId || req.query?.personaId);
if (Number.isFinite(personaId)) {
try {
const persona = getDb().prepare(
'SELECT id FROM user_personas WHERE id = ? AND user_id = ?'
).get(personaId, user.id);
if (!persona) return res.status(403).json({ error: 'Not your persona' });
getDb().prepare('UPDATE user_personas SET avatar = ? WHERE id = ?').run(avatarUrl, personaId);
} catch (dbErr) {
console.error('Persona avatar DB error:', dbErr);
return res.status(500).json({ error: 'Failed to save avatar' });
}
}
res.json({ url: avatarUrl });
});
});
// ── 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) ────────────────
app.get('/invite/:vanityCode', (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');
}
const { getDb } = require('./src/database');
const db = getDb();
// Accept either the legacy single vanity_code setting or any managed invite
// code (from the invite-link menu). The frontend auto-joins from ?invite=;
// enabled/expiry/use-limit are enforced server-side when join-channel fires.
const row = db.prepare("SELECT value FROM server_settings WHERE key = 'vanity_code'").get();
const isLegacyVanity = row && row.value === vanityCode;
const managed = isLegacyVanity ? null : db.prepare('SELECT 1 FROM invite_codes WHERE code = ?').get(vanityCode);
if (!isLegacyVanity && !managed) {
return res.status(404).send('Invite link not found or expired');
}
// Redirect to /app with the code as a query param — the frontend will auto-join
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 {}
}
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 {}
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 oidcConfig = require('./src/oidc').getOidcConfig();
let storedPublishedThemes = [];
try { storedPublishedThemes = JSON.parse(publishedThemesRow?.value || '[]'); } catch {}
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 || '',
// 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 : ''
});
} 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');
// 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 }, (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 }, (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) {
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 upload (authenticated + not banned) ────────────
app.post('/api/upload', 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' });
// Check if user is banned
const { getDb } = require('./src/database');
const ban = getDb().prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (ban) return res.status(403).json({ error: 'Banned users cannot upload' });
// Enforce upload_files permission (admin always allowed)
if (!verifyAdminFromDb(user)) {
const hasPerm = getDb().prepare(`
SELECT 1 FROM role_permissions rp
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = ? AND rp.permission = 'upload_files' AND rp.allowed = 1 LIMIT 1
`).get(user.id);
if (!hasPerm) return res.status(403).json({ error: 'You don\'t have permission to upload files' });
}
upload.single('image')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// Enforce DB-configurable max upload size (same setting as general file uploads)
const maxMbRow = getDb().prepare("SELECT value FROM server_settings WHERE key = 'max_upload_mb'").get();
const maxBytes = (parseInt(maxMbRow?.value) || 25) * 1024 * 1024;
if (req.file.size > maxBytes) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: `Image too large (max ${maxMbRow?.value || 25} MB)` });
}
// Validate file magic bytes (don't trust MIME type alone)
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: 'File content does not match image type' });
}
} catch {
try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to validate file' });
}
// Force safe extension based on validated mimetype (prevent HTML/SVG upload)
const mimeToExt = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' };
const safeExt = mimeToExt[req.file.mimetype];
if (!safeExt) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'Invalid file type' });
}
// Rename file to use safe extension if it doesn't already match
const currentExt = path.extname(req.file.filename).toLowerCase();
if (currentExt !== safeExt) {
const safeName = req.file.filename.replace(/\.[^.]+$/, '') + safeExt;
const oldPath = req.file.path;
const newPath = path.join(uploadDir, safeName);
fs.renameSync(oldPath, newPath);
recordUploadOwnership(user.id, safeName, req.file.size, uploadScopeFromRequest(req));
return res.json({ url: `/uploads/${safeName}` });
}
recordUploadOwnership(user.id, req.file.filename, req.file.size, uploadScopeFromRequest(req));
res.json({ url: `/uploads/${req.file.filename}` });
});
});
// ── General file upload (authenticated + not banned) ─────
app.post('/api/upload-file', 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' });
const { getDb } = require('./src/database');
const ban = getDb().prepare('SELECT id FROM bans WHERE user_id = ?').get(user.id);
if (ban) return res.status(403).json({ error: 'Banned users cannot upload' });
// Enforce upload_files permission (admin always allowed)
if (!verifyAdminFromDb(user)) {
const hasPerm = getDb().prepare(`
SELECT 1 FROM role_permissions rp
JOIN user_roles ur ON rp.role_id = ur.role_id
WHERE ur.user_id = ? AND rp.permission = 'upload_files' AND rp.allowed = 1 LIMIT 1
`).get(user.id);
if (!hasPerm) return res.status(403).json({ error: 'You don\'t have permission to upload files' });
}
fileUpload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
// Enforce DB-configurable max upload size
const maxMbRow = getDb().prepare("SELECT value FROM server_settings WHERE key = 'max_upload_mb'").get();
const maxBytes = (parseInt(maxMbRow?.value) || 25) * 1024 * 1024;
if (req.file.size > maxBytes) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: `File too large (max ${maxMbRow?.value || 25} MB)` });
}
const isImage = /^image\//.test(req.file.mimetype);
// multer passes the raw bytes from the multipart header as a latin1 string;
// browsers encode filenames as UTF-8 bytes, so re-decode to recover the
// original text (fixes garbled Chinese/emoji/non-ASCII filenames).
const originalName = Buffer.from(req.file.originalname || 'file', 'latin1').toString('utf8');
const fileSize = req.file.size;
recordUploadOwnership(user.id, req.file.filename, fileSize, uploadScopeFromRequest(req));
res.json({
url: `/uploads/${req.file.filename}`,
originalName,
fileSize,
isImage,
mimetype: req.file.mimetype
});
});
});
// ── Flash ROM status & download ──────────────────────────
const ROMS_DIR = path.join(__dirname, 'public', 'games', 'roms');
const FLASH_ROM_MANIFEST = [
{ file: 'flight-759879f9.swf', url: 'https://raw.githubusercontent.com/ancsemi/Haven/ccf21d874c5502eefccc7a46fe525a793e0bc603/public/games/roms/flight-759879f9.swf', size: 8570000 },
{ file: 'learn-to-fly-3.swf', url: 'https://raw.githubusercontent.com/ancsemi/Haven/ccf21d874c5502eefccc7a46fe525a793e0bc603/public/games/roms/learn-to-fly-3.swf', size: 17340000 },
{ file: 'Bubble Tanks 3.swf', url: 'https://raw.githubusercontent.com/ancsemi/Haven/ccf21d874c5502eefccc7a46fe525a793e0bc603/public/games/roms/Bubble%20Tanks%203.swf', size: 3870000 },
{ file: 'tanks.swf', url: 'https://raw.githubusercontent.com/ancsemi/Haven/ccf21d874c5502eefccc7a46fe525a793e0bc603/public/games/roms/tanks.swf', size: 32000 },
{ file: 'SuperSmash.swf', url: 'https://raw.githubusercontent.com/ancsemi/Haven/ccf21d874c5502eefccc7a46fe525a793e0bc603/public/games/roms/SuperSmash.swf', size: 8830000 },
];
app.get('/api/flash-rom-status', (req, res) => {
const status = FLASH_ROM_MANIFEST.map(rom => ({
file: rom.file,
installed: fs.existsSync(path.join(ROMS_DIR, rom.file))
}));
const allInstalled = status.every(r => r.installed);
res.json({ allInstalled, roms: status });
});
app.post('/api/install-flash-roms', 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' });
// Only admins can trigger ROM downloads
const { getDb } = require('./src/database');
const adminRow = getDb().prepare('SELECT is_admin FROM users WHERE id = ?').get(user.id);
if (!adminRow || !adminRow.is_admin) return res.status(403).json({ error: 'Only admins can install flash games' });
if (!fs.existsSync(ROMS_DIR)) fs.mkdirSync(ROMS_DIR, { recursive: true });
const results = [];
for (const rom of FLASH_ROM_MANIFEST) {
const dest = path.join(ROMS_DIR, rom.file);
if (fs.existsSync(dest)) { results.push({ file: rom.file, status: 'already-installed' }); continue; }
try {
const resp = await fetch(rom.url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const buffer = Buffer.from(await resp.arrayBuffer());
fs.writeFileSync(dest, buffer);
results.push({ file: rom.file, status: 'installed' });
} catch (err) {
results.push({ file: rom.file, status: 'error', error: err.message });
}
}
res.json({ results });
});
// (duplicate avatar handler removed — handled above at /api/upload-avatar)
// ── Built-in sounds (bundled with Haven, always available) ────
const BUILTIN_SOUNDS = [
{ name: 'AOL - Door Open', url: '/sounds/aol_door_open.mp3', builtin: true },
{ name: 'AOL - Door Close', url: '/sounds/aol_door_close.mp3', builtin: true },
{ name: "AOL - You've Got Mail", url: '/sounds/aol_got_mail.mp3', builtin: true },
{ name: 'AOL - Message', url: '/sounds/aol_message.mp3', builtin: true },
{ name: 'AOL - Files Done', url: '/sounds/aol_filesdone.mp3', builtin: true },
];
// (#5426) Custom sounds, emojis and stickers are uploaded/deleted over HTTP,
// so other connected clients never heard about the change and only saw it
// after a full app restart. Broadcast a lightweight signal so every client
// re-fetches the relevant library live. `io` is created later in the file, so
// resolve it at request time via app.set('io', io).
function broadcastLibraryUpdate(req, kind) {
try { req.app.get('io')?.emit('library-updated', { kind }); } catch {}
}
// ── Sound upload (admin only, wav/mp3/ogg, configurable max size) ────
function createSoundUpload() {
const { getDb } = require('./src/database');
const maxKb = parseInt(getDb().prepare('SELECT value FROM server_settings WHERE key = ?').get('max_sound_kb')?.value) || 1024;
return multer({
storage: uploadStorage,
limits: { fileSize: maxKb * 1024 },
fileFilter: (req, file, cb) => {
if (/^audio\/(mpeg|ogg|wav|webm)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only audio files allowed (mp3, ogg, wav, webm)'));
}
});
}
app.post('/api/upload-sound', 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_soundboard')) return res.status(403).json({ error: 'Requires admin or Manage Soundboard permission' });
createSoundUpload().single('sound')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
let name = (req.body.name || '').trim().replace(/[^a-zA-Z0-9 _-]/g, '').replace(/\s+/g, ' ').trim();
if (!name) name = path.basename(req.file.filename, path.extname(req.file.filename));
if (name.length > 30) name = name.slice(0, 30);
const { getDb } = require('./src/database');
try {
getDb().prepare(
'INSERT OR REPLACE INTO custom_sounds (name, filename, uploaded_by) VALUES (?, ?, ?)'
).run(name, req.file.filename, user.id);
broadcastLibraryUpdate(req, 'sounds');
res.json({ name, url: `/uploads/${req.file.filename}` });
} catch { res.status(500).json({ error: 'Failed to save sound' }); }
});
});
app.get('/api/sounds', (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');
try {
const disabledRows = getDb().prepare('SELECT name FROM disabled_builtin_sounds').all();
const disabledSet = new Set(disabledRows.map(r => r.name));
const enabledBuiltins = BUILTIN_SOUNDS.filter(s => !disabledSet.has(s.name));
const custom = getDb().prepare('SELECT name, filename FROM custom_sounds ORDER BY name').all();
const customList = custom.map(s => ({ name: s.name, url: `/uploads/${s.filename}` }));
res.json({ sounds: [...enabledBuiltins, ...customList] });
} catch { res.json({ sounds: [...BUILTIN_SOUNDS] }); }
});
app.delete('/api/sounds/:name', (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_soundboard')) return res.status(403).json({ error: 'Requires admin or Manage Soundboard permission' });
const name = req.params.name;
const { getDb } = require('./src/database');
try {
// Built-in sounds are disabled by adding them to a blocklist (they can't be physically deleted)
if (BUILTIN_SOUNDS.some(s => s.name === name)) {
getDb().prepare('INSERT OR IGNORE INTO disabled_builtin_sounds (name) VALUES (?)').run(name);
broadcastLibraryUpdate(req, 'sounds');
return res.json({ ok: true });
}
const row = getDb().prepare('SELECT filename FROM custom_sounds WHERE name = ?').get(name);
if (row) {
try { fs.unlinkSync(path.join(uploadDir, row.filename)); } catch {}
getDb().prepare('DELETE FROM custom_sounds WHERE name = ?').run(name);
}
broadcastLibraryUpdate(req, 'sounds');
res.json({ ok: true });
} catch { res.status(500).json({ error: 'Failed to delete sound' }); }
});
app.patch('/api/sounds/:name', (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_soundboard')) return res.status(403).json({ error: 'Requires admin or Manage Soundboard permission' });
const oldName = req.params.name;
if (BUILTIN_SOUNDS.some(s => s.name === oldName)) return res.status(403).json({ error: 'Cannot rename built-in sounds' });
let newName = (req.body.newName || '').trim().replace(/[^a-zA-Z0-9 _-]/g, '').replace(/\s+/g, ' ').trim();
if (!newName || newName.length > 30) return res.status(400).json({ error: 'Invalid new name' });
const { getDb } = require('./src/database');
try {
const row = getDb().prepare('SELECT id FROM custom_sounds WHERE name = ?').get(oldName);
if (!row) return res.status(404).json({ error: 'Sound not found' });
const existing = getDb().prepare('SELECT id FROM custom_sounds WHERE name = ? AND name != ?').get(newName, oldName);
if (existing) return res.status(409).json({ error: 'Name already taken' });
getDb().prepare('UPDATE custom_sounds SET name = ? WHERE name = ?').run(newName, oldName);
broadcastLibraryUpdate(req, 'sounds');
res.json({ ok: true, name: newName });
} catch { res.status(500).json({ error: 'Failed to rename sound' }); }
});
// -- User sound preferences ---------------------------------------------------
app.get('/api/user-sound-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' });
const { getDb } = require('./src/database');
try {
const rows = getDb().prepare('SELECT sound_name, hidden, custom_order FROM sound_preferences WHERE user_id = ?').all(user.id);
const prefs = {};
rows.forEach(r => { prefs[r.sound_name] = { hidden: !!r.hidden, customOrder: r.custom_order }; });
res.json({ prefs });
} catch { res.json({ prefs: {} }); }
});
app.post('/api/user-sound-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' });
const { prefs } = req.body || {};
if (!prefs || typeof prefs !== 'object') return res.status(400).json({ error: 'Invalid prefs' });
const { getDb } = require('./src/database');
try {
const db = getDb();
const upsert = db.prepare('INSERT INTO sound_preferences (user_id, sound_name, hidden, custom_order) VALUES (?, ?, ?, ?) ON CONFLICT(user_id, sound_name) DO UPDATE SET hidden = excluded.hidden, custom_order = excluded.custom_order');
const runMany = db.transaction(() => { Object.entries(prefs).forEach(([name, pref]) => { upsert.run(user.id, name, pref.hidden ? 1 : 0, pref.customOrder ?? null); }); });
runMany();
res.json({ ok: true });
} catch { res.status(500).json({ error: 'Failed to save prefs' }); }
});
// ── Custom emoji upload (admin only, image, configurable max size) ──
function createEmojiUpload() {
const { getDb } = require('./src/database');
const maxKb = parseInt(getDb().prepare('SELECT value FROM server_settings WHERE key = ?').get('max_emoji_kb')?.value) || 256;
return multer({
storage: uploadStorage,
limits: { fileSize: maxKb * 1024 },
fileFilter: (req, file, cb) => {
if (/^image\/(png|gif|webp|jpeg)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only images allowed (png, gif, webp, jpg)'));
}
});
}
app.post('/api/upload-emoji', 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_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Emojis permission' });
createEmojiUpload().single('emoji')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
let name = (req.body.name || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
if (!name) name = path.basename(req.file.filename, path.extname(req.file.filename));
if (name.length > 30) name = name.slice(0, 30);
const { getDb } = require('./src/database');
try {
getDb().prepare(
'INSERT OR REPLACE INTO custom_emojis (name, filename, uploaded_by) VALUES (?, ?, ?)'
).run(name, req.file.filename, user.id);
broadcastLibraryUpdate(req, 'emojis');
res.json({ name, url: `/uploads/${req.file.filename}` });
} catch { res.status(500).json({ error: 'Failed to save emoji' }); }
});
});
// ── Bulk emoji upload (multiple files, auto-named from filenames) ──
app.post('/api/upload-emojis', 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_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Emojis permission' });
createEmojiUpload().array('emojis', 50)(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' });
const { getDb } = require('./src/database');
const db = getDb();
const results = [];
const errors = [];
const insert = db.prepare('INSERT OR REPLACE INTO custom_emojis (name, filename, uploaded_by) VALUES (?, ?, ?)');
for (const file of req.files) {
let name = path.basename(file.originalname, path.extname(file.originalname))
.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
if (!name) name = path.basename(file.filename, path.extname(file.filename));
if (name.length > 30) name = name.slice(0, 30);
try {
insert.run(name, file.filename, user.id);
results.push({ name, url: `/uploads/${file.filename}` });
} catch (e) {
errors.push({ name, error: e.message });
}
}
if (results.length) broadcastLibraryUpdate(req, 'emojis');
res.json({ uploaded: results, errors });
});
});
app.get('/api/emojis', (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');
try {
const emojis = getDb().prepare('SELECT name, filename FROM custom_emojis ORDER BY name').all();
res.json({ emojis: emojis.map(e => ({ name: e.name, url: `/uploads/${e.filename}` })) });
} catch { res.json({ emojis: [] }); }
});
// Full Unicode emoji list (built from emoji-test.txt), rendered client-side
// with the OS font. Falls back to the client's built-in list when unavailable.
app.get('/api/standard-emojis', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token || !verifyToken(token)) return res.status(401).json({ error: 'Unauthorized' });
res.json(require('./src/emoji').getEmojiData() || { categories: {}, names: {}, modifierBase: [] });
});
app.delete('/api/emojis/:name', (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_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Emojis permission' });
const name = req.params.name;
const { getDb } = require('./src/database');
try {
const row = getDb().prepare('SELECT filename FROM custom_emojis WHERE name = ?').get(name);
if (row) {
try { fs.unlinkSync(path.join(uploadDir, row.filename)); } catch {}
getDb().prepare('DELETE FROM custom_emojis WHERE name = ?').run(name);
}
broadcastLibraryUpdate(req, 'emojis');
res.json({ ok: true });
} catch { res.status(500).json({ error: 'Failed to delete emoji' }); }
});
// ── Stickers (admin/manage_stickers-only upload, anyone can list/send) ──
// (#5335) `manage_stickers` is the canonical permission. We still accept
// `manage_emojis` as a fallback so anyone who already had emoji-management
// access keeps sticker access without an explicit re-grant.
// Stored under uploads/stickers/<file> so message rendering can detect
// them by URL prefix and render at sticker dimensions.
const STICKERS_DIR = path.join(uploadDir, 'stickers');
try { fs.mkdirSync(STICKERS_DIR, { recursive: true }); } catch {}
// (#5335) Seed a small starter pack on first run so the picker isn't empty
// out of the box. Files in public/starter-stickers/ are copied into
// uploads/stickers/ and registered in the `stickers` table under the
// "Starter" pack — but only if there are zero stickers in the DB. Once
// any sticker exists we leave things alone so admin uploads or deletions
// aren't trampled on next restart.
function seedStarterStickers() {
try {
const { getDb } = require('./src/database');
const db = getDb();
const existing = db.prepare('SELECT COUNT(*) as c FROM stickers').get();
if (existing && existing.c > 0) return;
const seedDir = path.join(__dirname, 'public', 'starter-stickers');
if (!fs.existsSync(seedDir)) return;
const files = fs.readdirSync(seedDir).filter(f => /\.(svg|png|gif|webp|jpg|jpeg)$/i.test(f));
const insert = db.prepare(
'INSERT OR IGNORE INTO stickers (name, pack_name, filename, uploaded_by) VALUES (?, ?, ?, NULL)'
);
let seeded = 0;
for (const file of files) {
try {
const ext = path.extname(file).toLowerCase();
const baseName = path.basename(file, ext).toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (!baseName) continue;
const destName = `starter-${baseName}${ext}`;
const destPath = path.join(STICKERS_DIR, destName);
if (!fs.existsSync(destPath)) fs.copyFileSync(path.join(seedDir, file), destPath);
insert.run(baseName, 'Starter', destName);
seeded++;
} catch {}
}
if (seeded > 0) console.log(`[stickers] Seeded ${seeded} starter sticker(s) into the "Starter" pack.`);
} catch (err) {
// Non-fatal — the server runs fine without the starter pack.
console.warn('[stickers] Could not seed starter pack:', err?.message || err);
}
}
const stickerStorage = multer.diskStorage({
destination: STICKERS_DIR,
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
}
});
function createStickerUpload() {
const { getDb } = require('./src/database');
// Stickers are larger than emojis by design — separate setting, default 1 MB.
const maxKb = parseInt(getDb().prepare('SELECT value FROM server_settings WHERE key = ?').get('max_sticker_kb')?.value) || 1024;
return multer({
storage: stickerStorage,
limits: { fileSize: maxKb * 1024 },
fileFilter: (req, file, cb) => {
if (/^image\/(png|gif|webp|jpeg)$/.test(file.mimetype)) cb(null, true);
else cb(new Error('Only images allowed (png, gif, webp, jpg)'));
}
});
}
app.post('/api/upload-sticker', 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_stickers') && !userHasPermission(user.id, 'manage_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Stickers permission' });
createStickerUpload().single('sticker')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
let name = (req.body.name || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
if (!name) name = path.basename(req.file.filename, path.extname(req.file.filename));
if (name.length > 40) name = name.slice(0, 40);
let pack = (req.body.pack_name || '').trim().slice(0, 40);
if (!pack) pack = 'General';
const { getDb } = require('./src/database');
try {
getDb().prepare(
'INSERT OR REPLACE INTO stickers (name, pack_name, filename, uploaded_by) VALUES (?, ?, ?, ?)'
).run(name, pack, req.file.filename, user.id);
broadcastLibraryUpdate(req, 'stickers');
res.json({ name, pack_name: pack, url: `/uploads/stickers/${req.file.filename}` });
} catch { res.status(500).json({ error: 'Failed to save sticker' }); }
});
});
app.post('/api/upload-stickers', 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_stickers') && !userHasPermission(user.id, 'manage_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Stickers permission' });
createStickerUpload().array('stickers', 50)(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.files || req.files.length === 0) return res.status(400).json({ error: 'No files uploaded' });
let pack = (req.body.pack_name || '').trim().slice(0, 40);
if (!pack) pack = 'General';
const { getDb } = require('./src/database');
const db = getDb();
const results = [];
const errors = [];
const insert = db.prepare('INSERT OR REPLACE INTO stickers (name, pack_name, filename, uploaded_by) VALUES (?, ?, ?, ?)');
for (const file of req.files) {
let name = path.basename(file.originalname, path.extname(file.originalname))
.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase();
if (!name) name = path.basename(file.filename, path.extname(file.filename));
if (name.length > 40) name = name.slice(0, 40);
try {
insert.run(name, pack, file.filename, user.id);
results.push({ name, pack_name: pack, url: `/uploads/stickers/${file.filename}` });
} catch (e) {
errors.push({ name, error: e.message });
}
}
if (results.length) broadcastLibraryUpdate(req, 'stickers');
res.json({ uploaded: results, errors });
});
});
app.get('/api/stickers', (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');
try {
const rows = getDb().prepare('SELECT id, name, pack_name, filename FROM stickers ORDER BY pack_name COLLATE NOCASE, name COLLATE NOCASE').all();
res.json({ stickers: rows.map(r => ({ id: r.id, name: r.name, pack_name: r.pack_name, url: `/uploads/stickers/${r.filename}` })) });
} catch { res.json({ stickers: [] }); }
});
app.delete('/api/stickers/:name', (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_stickers') && !userHasPermission(user.id, 'manage_emojis')) return res.status(403).json({ error: 'Requires admin or Manage Stickers permission' });
const name = req.params.name;
const { getDb } = require('./src/database');
try {
const row = getDb().prepare('SELECT filename FROM stickers WHERE name = ?').get(name);
if (row) {
try { fs.unlinkSync(path.join(STICKERS_DIR, row.filename)); } catch {}
getDb().prepare('DELETE FROM stickers WHERE name = ?').run(name);
}
broadcastLibraryUpdate(req, 'stickers');
res.json({ ok: true });
} catch { res.status(500).json({ error: 'Failed to delete sticker' }); }
});
// ── GIF search proxy (Tenor v2 or GIPHY — keeps keys server-side) ──
// Tenor is the preferred provider: GIPHY stopped issuing API keys to
// new applications, so fresh installs can no longer configure it.
// Existing GIPHY keys keep working unchanged.
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 tenorKey = readSetting('tenor_api_key') || process.env.TENOR_API_KEY || '';
if (tenorKey) return { provider: 'tenor', key: tenorKey };
const giphyKey = readSetting('giphy_api_key') || process.env.GIPHY_API_KEY || '';
if (giphyKey) return { provider: 'giphy', key: giphyKey };
return null;
}
// Both 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 === '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, (err) => {
if (err) return res.status(400).json({ error: err.message });
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 {} return res.status(400).json({ error: 'Failed to validate' }); }
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);
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, (err) => {
if (err) return res.status(400).json({ error: err.message });
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 {} return res.status(400).json({ error: 'Failed to validate' }); }
const iconUrl = `/uploads/${req.file.filename}`;
res.json({ path: iconUrl });
});
});
// ── Admin: Server backup download (admin only) ──
// Configurable per-section via ?include=channels,users,settings,messages,files
// Backwards-compat: ?mode=structure → channels,users,settings ;
// ?mode=full → channels,users,settings,messages,files
// Token may be passed via ?token=... so the browser can trigger a normal download.
const ALL_BACKUP_SECTIONS = ['channels', 'users', 'settings', 'messages', 'dms', 'files'];
// Resolve the requested sections into a concrete backup plan (sync, no IO).
function backupPlan(includeRaw) {
let include = Array.isArray(includeRaw)
? includeRaw.map(s => String(s).trim().toLowerCase()).filter(s => ALL_BACKUP_SECTIONS.includes(s))
: ALL_BACKUP_SECTIONS.slice();
if (!include.length) include = ALL_BACKUP_SECTIONS.slice();
const has = (s) => include.includes(s);
const mode = (has('messages') && has('files')) ? 'full' : 'partial';
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `haven-backup-${mode === 'full' ? 'full' : include.join('-')}-${ts}.zip`;
return { include, has, mode, filename };
}
// Build a backup zip and pipe it into `destStream` (a file write stream OR the
// HTTP response). Resolves when the archive has been fully written.
//
// Everything is streamed via `archiver` rather than built in memory. The old
// adm-zip path called `zip.toBuffer()`, holding every upload plus the whole
// compressed archive in RAM at once — a ~30GB backup blew past Node's Buffer
// limit (RangeError) and the heap (OOM), crashing the server. Streaming to the
// response (instead of building a temp file first) also means bytes start
// flowing immediately, so a large manual download no longer sits silent long
// enough for a proxy in front of Haven to time out with a 502. See issue #5434.
function pipeBackupArchive(plan, destStream) {
const archiver = require('archiver');
const { include, has, mode } = plan;
return new Promise((resolve, reject) => {
let tmpDb = null;
let settled = false;
const cleanup = () => { if (tmpDb) { try { fs.unlinkSync(tmpDb); } catch {} } };
// Resolve/reject exactly once, always after the temp DB clone is removed.
const finish = (err) => {
if (settled) return;
settled = true;
cleanup();
if (err) reject(err);
else resolve(plan);
};
try {
const { getDb } = require('./src/database');
const db = getDb();
const archive = archiver('zip', { zlib: { level: 6 } });
// 'finish' fires for both fs write streams and the HTTP response once all
// bytes are written; 'close' covers a client that disconnects mid-stream.
destStream.on('finish', () => finish());
destStream.on('close', () => finish());
destStream.on('error', finish);
archive.on('error', finish);
// ENOENT here just means a file vanished mid-walk (e.g. an attachment was
// deleted) — skip it rather than failing the whole backup.
archive.on('warning', (w) => { if (w.code !== 'ENOENT') finish(w); });
archive.pipe(destStream);
const manifest = {
app: 'haven',
version: require('./package.json').version,
exportedAt: new Date().toISOString(),
mode,
include,
serverName: process.env.SERVER_NAME || 'Haven',
};
archive.append(Buffer.from(JSON.stringify(manifest, null, 2)), { name: 'manifest.json' });
const structureTables = [];
if (has('channels')) structureTables.push('channels', 'roles', 'role_permissions', 'user_roles', 'channel_members');
if (has('users')) structureTables.push('users');
if (has('settings')) structureTables.push('server_settings', 'whitelist');
if (structureTables.length) {
const data = {};
for (const tbl of structureTables) {
try { data[tbl] = db.prepare(`SELECT * FROM ${tbl}`).all(); }
catch { data[tbl] = []; }
}
// Filter out DM channels (and their members) when DMs aren't included.
// DM bodies are E2E-encrypted, but the channel rows still leak who
// talked to whom — keep the metadata out unless the admin opted in.
if (!has('dms') && data.channels) {
const dmChannelIds = new Set(data.channels.filter(c => c.is_dm).map(c => c.id));
data.channels = data.channels.filter(c => !c.is_dm);
if (data.channel_members) {
data.channel_members = data.channel_members.filter(m => !dmChannelIds.has(m.channel_id));
}
}
if (data.users) {
data.users = data.users.map(u => {
const safe = { ...u };
delete safe.password_hash;
delete safe.password_version;
delete safe.totp_secret;
delete safe.totp_backup_codes;
delete safe.recovery_codes_hash;
delete safe.recovery_codes;
delete safe.email;
return safe;
});
}
if (data.server_settings) {
const SENSITIVE_KEYS = new Set(['vanity_code', 'server_invite_code']);
data.server_settings = data.server_settings.filter(r => !SENSITIVE_KEYS.has(r.key));
}
archive.append(Buffer.from(JSON.stringify(data, null, 2)), { name: 'structure.json' });
}
if (has('messages')) {
tmpDb = path.join(DATA_DIR, `.backup-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch {}
const safePath = tmpDb.replace(/'/g, "''");
db.prepare(`VACUUM INTO '${safePath}'`).run();
// If DMs are NOT included, scrub them from the cloned DB so the backup
// doesn't ship encrypted-but-still-private DM ciphertext (or attachment
// refs) to wherever the admin stores their backup files.
if (!has('dms')) {
const Database = require('better-sqlite3');
const tmp = new Database(tmpDb);
try {
tmp.exec('DELETE FROM messages WHERE channel_id IN (SELECT id FROM channels WHERE is_dm = 1)');
tmp.exec('DELETE FROM channels WHERE is_dm = 1');
tmp.exec('VACUUM');
} finally {
tmp.close();
}
}
// Streamed from disk during finalize; the temp clone is unlinked in finish().
archive.file(tmpDb, { name: 'haven.db' });
}
if (has('files') && fs.existsSync(UPLOADS_DIR)) {
const walk = (dir, rel) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'deleted-attachments') continue;
if (!rel && entry.name === 'bot-audio') continue;
const full = path.join(dir, entry.name);
const sub = rel ? `${rel}/${entry.name}` : entry.name;
try {
if (entry.isFile()) archive.file(full, { name: `uploads/${sub}` });
else if (entry.isDirectory()) walk(full, sub);
} catch {}
}
};
walk(UPLOADS_DIR, '');
}
archive.finalize();
} catch (err) {
finish(err);
}
});
}
// Build a backup zip to a file on disk (used by the auto-backup scheduler).
// Returns a Promise<{ filePath, filename, mode, include }>.
function buildBackupFile(includeRaw, outPath) {
const plan = backupPlan(includeRaw);
const output = fs.createWriteStream(outPath);
return pipeBackupArchive(plan, output)
.then(() => ({ filePath: outPath, filename: plan.filename, mode: plan.mode, include: plan.include }))
.catch((err) => { try { fs.unlinkSync(outPath); } catch {} throw err; });
}
app.get('/api/admin/backup', async (req, res) => {
const token = req.query.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' });
// Resolve which sections to include
let include = [];
if (typeof req.query.include === 'string' && req.query.include.trim()) {
include = req.query.include.split(',');
} else if (req.query.mode === 'full') {
include = ALL_BACKUP_SECTIONS.slice();
} else {
include = ['channels', 'users', 'settings'];
}
// Stream the zip straight to the response. Building a temp file first meant a
// large (30GB) backup produced no response for minutes, so a proxy in front of
// Haven returned 502 and the download "failed" (#5434). Piping to res starts
// the bytes immediately and keeps the connection active; clear the inactivity
// timeout since a big backup takes longer than the 2 min socket timeout.
req.setTimeout(0);
res.setTimeout(0);
const plan = backupPlan(include);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${plan.filename}"`);
try {
await pipeBackupArchive(plan, res);
} catch (err) {
console.error('[Backup] Failed:', err);
if (!res.headersSent) res.status(500).json({ error: 'Backup failed: ' + err.message });
else { try { res.destroy(); } catch {} }
}
});
// ── Admin: Server backup restore (admin only, full backups only) ──
// Stages the uploaded backup, then schedules a process exit so the
// supervisor (Docker / systemd / installer service) restarts the server
// with the restored DB and uploads in place. The pre-restore data is
// preserved at haven.db.pre-restore / uploads.pre-restore for one cycle.
const restoreUpload = multer({
dest: path.join(DATA_DIR, 'tmp-restore'),
// Admin-only endpoint. A full backup that includes files can be very large
// (reporters hit 15GB+), and the old 4GB cap rejected the upload part-way,
// which surfaced to the browser as a "failed to fetch" (#5436). The practical
// limit is the host's disk, not this number.
limits: { fileSize: 512 * 1024 * 1024 * 1024 },
});
// Stream a full backup zip into staged DB + uploads on disk. Reads entries with
// yauzl (random access, low memory) instead of loading the whole archive into
// RAM the way adm-zip did, so restoring a large backup (15GB+) no longer OOMs
// or crashes the server (#5436). Resolves with the parsed manifest. Rejects with
// an Error whose .status is 400 for a bad/partial backup, or a generic error
// (treated as 500) for IO problems.
function extractFullBackup(zipPath, stagedDb, stagedUploads, onProgress) {
const yauzl = require('yauzl');
const bad = (msg) => Object.assign(new Error(msg), { status: 400 });
const readEntryBuffer = (zipfile, entry) => new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (err, rs) => {
if (err) return reject(err);
const chunks = [];
rs.on('data', c => chunks.push(c));
rs.on('end', () => resolve(Buffer.concat(chunks)));
rs.on('error', reject);
});
});
const streamEntryToFile = (zipfile, entry, dest) => new Promise((resolve, reject) => {
zipfile.openReadStream(entry, (err, rs) => {
if (err) return reject(err);
const ws = fs.createWriteStream(dest);
rs.on('error', reject);
ws.on('error', reject);
ws.on('finish', resolve);
rs.pipe(ws);
});
});
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zipfile) => {
if (err) return reject(bad('Invalid backup: not a readable zip file'));
const entries = [];
zipfile.on('error', reject);
zipfile.on('entry', (entry) => { entries.push(entry); zipfile.readEntry(); });
zipfile.on('end', async () => {
try {
const find = (n) => entries.find(e => e.fileName === n);
const manifestEntry = find('manifest.json');
if (!manifestEntry) throw bad('Invalid backup: missing manifest.json');
let manifest;
try { manifest = JSON.parse((await readEntryBuffer(zipfile, manifestEntry)).toString('utf8')); }
catch { throw bad('Invalid backup: corrupt manifest.json'); }
if (manifest.app !== 'haven') throw bad('Not a Haven backup file');
if (manifest.mode !== 'full') throw bad('Only full backups can be restored automatically. Structure-only backups must be re-imported manually.');
const dbEntry = find('haven.db');
if (!dbEntry) throw bad('Invalid full backup: missing haven.db');
// Progress accounting: total = the DB clone + every upload file
// (uncompressed bytes). Emitted (throttled to ~400ms) via onProgress
// so the admin's restore UI can show a real extraction bar instead of
// an opaque multi-minute wait on large backups (#5438).
const uploadEntries = entries.filter(e => e.fileName.startsWith('uploads/') && !e.fileName.endsWith('/'));
const bytesTotal = (dbEntry.uncompressedSize || 0) +
uploadEntries.reduce((sum, e) => sum + (e.uncompressedSize || 0), 0);
let bytesDone = 0;
let lastEmit = 0;
const emitProgress = (force) => {
if (typeof onProgress !== 'function') return;
const now = Date.now();
if (!force && now - lastEmit < 400) return;
lastEmit = now;
try { onProgress({ phase: 'extract', bytesDone, bytesTotal }); } catch {}
};
emitProgress(true);
// Stage the DB clone (streamed from the zip to disk).
await streamEntryToFile(zipfile, dbEntry, stagedDb);
bytesDone += dbEntry.uncompressedSize || 0;
emitProgress(true);
// Stage uploads, one entry at a time, with a path-traversal guard so a
// crafted entry name can't write outside the staging directory.
if (fs.existsSync(stagedUploads)) fs.rmSync(stagedUploads, { recursive: true, force: true });
if (uploadEntries.length) {
fs.mkdirSync(stagedUploads, { recursive: true });
const root = path.resolve(stagedUploads);
for (const ue of uploadEntries) {
const rel = ue.fileName.slice('uploads/'.length);
if (!rel) continue;
const dest = path.resolve(root, rel);
if (dest !== root && !dest.startsWith(root + path.sep)) continue; // reject ../ escapes
fs.mkdirSync(path.dirname(dest), { recursive: true });
await streamEntryToFile(zipfile, ue, dest);
bytesDone += ue.uncompressedSize || 0;
emitProgress(false);
}
}
emitProgress(true);
zipfile.close();
resolve(manifest);
} catch (e) {
try { zipfile.close(); } catch {}
reject(e);
}
});
zipfile.readEntry();
});
});
}
app.post('/api/admin/restore', (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' });
// A large restore uploads a multi-GB body and then stages it to disk, both of
// which run far longer than the 2 min socket inactivity timeout. Clear the
// timeout on this admin-only connection so it isn't killed mid-restore (#5436).
req.setTimeout(0);
res.setTimeout(0);
const tmpDir = path.join(DATA_DIR, 'tmp-restore');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
restoreUpload.single('backup')(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
const cleanupTmp = () => { try { fs.unlinkSync(req.file.path); } catch {} };
const stagedDb = DB_PATH + '.restore';
const stagedUploads = UPLOADS_DIR + '.restore';
// Push extraction progress to the requesting admin's live socket(s) so the
// restore UI can show a real "extracting" bar during the long staging step.
const sendRestoreProgress = (p) => {
if (!io) return;
for (const [, s] of io.sockets.sockets) {
if (s.user && s.user.id === user.id) { try { s.emit('restore-progress', p); } catch {} }
}
};
let manifest;
try {
manifest = await extractFullBackup(req.file.path, stagedDb, stagedUploads, sendRestoreProgress);
} catch (e) {
cleanupTmp();
try { fs.unlinkSync(stagedDb); } catch {}
try { fs.rmSync(stagedUploads, { recursive: true, force: true }); } catch {}
const status = e && e.status ? e.status : 500;
if (status === 500) console.error('[Restore] Failed:', e);
if (!res.headersSent) res.status(status).json({ error: status === 500 ? ('Restore failed: ' + e.message) : e.message });
return;
}
cleanupTmp();
res.json({
ok: true,
message: 'Backup staged. Server will restart in ~2 seconds to apply. If the server does not come back up, your hosting setup may not auto-restart — start Haven manually.',
scheduled: true,
});
// Apply swap and exit so the supervisor restarts us cleanly
setTimeout(() => {
console.log('🔄 Applying staged backup restore and restarting...');
botAudioManager?.shutdown();
try {
if (fs.existsSync(stagedDb)) {
try { fs.copyFileSync(DB_PATH, DB_PATH + '.pre-restore'); } catch {}
// Remove stale WAL/SHM so SQLite reopens against the restored file
try { fs.unlinkSync(DB_PATH + '-wal'); } catch {}
try { fs.unlinkSync(DB_PATH + '-shm'); } catch {}
fs.renameSync(stagedDb, DB_PATH);
}
if (fs.existsSync(stagedUploads)) {
const oldUploads = UPLOADS_DIR + '.pre-restore';
if (fs.existsSync(oldUploads)) fs.rmSync(oldUploads, { recursive: true, force: true });
if (fs.existsSync(UPLOADS_DIR)) fs.renameSync(UPLOADS_DIR, oldUploads);
fs.renameSync(stagedUploads, UPLOADS_DIR);
}
} catch (e) {
console.error('[Restore] Swap failed:', e);
}
process.exit(0);
}, 1500);
});
});
// ── 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, (err) => {
if (err) return res.status(400).json({ error: err.message });
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 {} return res.status(400).json({ error: 'Failed to validate' }); }
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);
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 preview (Open Graph metadata) ──────────────────
const linkPreviewCache = new Map(); // url → { data, ts }
const PREVIEW_CACHE_TTL = 30 * 60 * 1000; // 30 min
const PREVIEW_MAX_SIZE = 256 * 1024; // only read first 256 KB of page
// Decode common HTML entities in OG-scraped attribute values.
// Without this, image URLs containing '&amp;' get double-encoded on the client.
function decodeHtmlEntities(str) {
if (!str) return str;
return str
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCharCode(parseInt(h, 16)))
.replace(/&quot;/gi, '"')
.replace(/&apos;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&');
}
const dns = require('dns');
const { promisify } = require('util');
const dnsResolve = promisify(dns.resolve4);
// Rate limit link preview fetches (per IP, separate from upload limiter).
// Returns true when the request is within the window, false if the caller
// should serve a 429. The route handler invokes this AFTER the cache
// lookup, so cache hits never consume a rate-limit token — fixes a bug
// where reopening a chat with many links 429'd legitimate fresh requests
// because each cached preview burned a slot. (#5337)
const previewLimitStore = new Map();
function previewLimiterCheck(req) {
const ip = req.ip || req.socket.remoteAddress;
const now = Date.now();
const windowMs = 60 * 1000;
const maxReqs = 60; // 60 previews/min/user (was 30; bumped per #5337)
if (!previewLimitStore.has(ip)) previewLimitStore.set(ip, []);
const stamps = previewLimitStore.get(ip).filter(t => now - t < windowMs);
previewLimitStore.set(ip, stamps);
if (stamps.length >= maxReqs) return false;
stamps.push(now);
return true;
}
setInterval(() => { const now = Date.now(); for (const [ip, t] of previewLimitStore) { const f = t.filter(x => now - x < 60000); if (!f.length) previewLimitStore.delete(ip); else previewLimitStore.set(ip, f); } }, 5 * 60 * 1000);
// Check if an IP is private/internal
function isPrivateIP(ip) {
if (!ip) return true;
return ip === '127.0.0.1' || ip === '0.0.0.0' || ip === '::1' || ip === '::' ||
ip.startsWith('10.') || ip.startsWith('192.168.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
ip.startsWith('169.254.') || ip.startsWith('fc00:') || ip.startsWith('fd') ||
ip.startsWith('fe80:');
}
// Check if a hostname is private/internal (SSRF layer 1)
function isPrivateHostname(hostname) {
const host = hostname.toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' ||
host === '::1' || host === '[::1]' ||
host.startsWith('10.') || host.startsWith('192.168.') ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
host === '169.254.169.254' ||
host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost');
}
// Validate a URL is safe to fetch (not internal/private) — checks hostname + DNS
// Set ALLOW_PRIVATE_PREVIEWS=true in .env to allow link previews for local/private services
const allowPrivatePreviews = (process.env.ALLOW_PRIVATE_PREVIEWS || '').toLowerCase() === 'true';
async function validateUrlSafe(urlStr) {
const parsed = new URL(urlStr);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only http/https URLs allowed');
}
if (!allowPrivatePreviews) {
if (isPrivateHostname(parsed.hostname)) {
throw new Error('Private addresses not allowed');
}
// SSRF layer 2: DNS resolution check (defeats DNS rebinding)
try {
const addresses = await dnsResolve(parsed.hostname);
if (addresses.some(isPrivateIP)) {
throw new Error('Private addresses not allowed');
}
} catch (err) {
if (err.message === 'Private addresses not allowed') throw err;
// DNS resolution failed — could be IPv6-only or non-existent; allow fetch to fail naturally
}
}
return parsed;
}
// ── Media proxy (v3.43.0) ─────────────────────────────────
// Clients never fetch remote media directly any more; they ask Haven, Haven
// fetches once and caches on disk. Closes the passive IP leak completely
// rather than only for non-allowlisted domains, and keeps embeds working
// after the origin expires.
const mediaProxy = require('./src/mediaProxy');
mediaProxy.loadIndex();
function mediaProxyEnabled() {
try {
const { getDb } = require('./src/database');
const row = getDb().prepare("SELECT value FROM server_settings WHERE key = 'media_proxy_enabled'").get();
return !row || row.value !== 'false';
} catch { return true; }
}
app.set('mediaProxyEnabled', mediaProxyEnabled);
// Hands the client its short-lived media token. Authenticated normally; the
// token it returns is what <img> tags carry, since they cannot send headers.
app.get('/api/media-token', (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 (!mediaProxyEnabled()) return res.json({ enabled: false, token: null });
res.json({ enabled: true, token: mediaProxy.issueToken(user.id) });
});
// Per-user rate limit. Generous, because opening a busy channel legitimately
// requests many images at once; the cache means repeats are nearly free.
const _mediaRate = new Map(); // userId -> { count, resetAt }
function _mediaRateOk(userId) {
const now = Date.now();
let e = _mediaRate.get(userId);
if (!e || now > e.resetAt) { e = { count: 0, resetAt: now + 60000 }; _mediaRate.set(userId, e); }
e.count++;
return e.count <= 600;
}
setInterval(() => {
const now = Date.now();
for (const [uid, e] of _mediaRate) if (now > e.resetAt + 120000) _mediaRate.delete(uid);
}, 5 * 60 * 1000).unref?.();
app.get('/api/media-proxy', async (req, res) => {
if (!mediaProxyEnabled()) return res.status(404).json({ error: 'Media proxy disabled' });
const userId = mediaProxy.verifyToken((req.query.mt || '').trim());
if (!userId) return res.status(401).json({ error: 'Invalid or expired media token' });
if (!_mediaRateOk(userId)) return res.status(429).json({ error: 'Rate limited' });
const url = (req.query.url || '').trim();
if (!url || url.length > 2048) return res.status(400).json({ error: 'Missing or oversized url' });
// Serve straight from disk when we already hold it — no upstream request,
// so a link that has since expired or gone offline still renders.
const send = (item) => {
res.set('Content-Type', item.type);
res.set('Content-Length', String(item.size));
// Immutable: the cache key is a hash of the source URL, so a given proxy
// URL always resolves to the same bytes.
res.set('Cache-Control', 'public, max-age=604800, immutable');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Cross-Origin-Resource-Policy', 'same-origin');
return res.sendFile(item.path);
};
const cached = mediaProxy.get(url);
if (cached) return send(cached);
try {
const item = await mediaProxy.fetchAndCache(url, validateUrlSafe);
return send(item);
} catch (err) {
// A transparent 1x1 would silently hide broken images; a status code lets
// the client fall back to its own placeholder.
return res.status(502).json({ error: String(err.message || 'fetch failed').slice(0, 120) });
}
});
app.get('/api/link-preview', 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 url = (req.query.url || '').trim();
if (!url) return res.status(400).json({ error: 'Missing url param' });
// Cache check FIRST — cache hits should never consume a rate-limit slot.
// Reopening a chat full of links was hitting 429 because the limiter ran
// before the cache lookup. (#5337)
const cached = linkPreviewCache.get(url);
if (cached && Date.now() - cached.ts < PREVIEW_CACHE_TTL) {
return res.json(cached.data);
}
// Cache miss — now apply the per-IP rate limit.
if (!previewLimiterCheck(req)) {
// Tell the client roughly when a slot frees so it can pace its retries
// instead of hammering (or, worse, silently dropping the embed). The
// client-side scheduler honours this header. (#5337 follow-up)
res.set('Retry-After', '3');
return res.status(429).json({ error: 'Rate limited — try again shortly' });
}
// Validate the initial URL is safe (protocol, hostname, DNS)
let parsed;
try {
parsed = await validateUrlSafe(url);
} catch (err) {
return res.status(400).json({ error: err.message || 'Invalid URL' });
}
// ── Auto-mod domain gate (v3.42.0) ──────────────────────
// This is the control that closes the passive IP leak, and it matters more
// than the click-through case. Haven renders og:image and bare image URLs
// straight from the third-party host in every viewer's browser, so a hostile
// link hands the attacker the IP and User-Agent of everyone who merely
// scrolls past the message. Refusing to unfurl a non-allowlisted host means
// no client is ever told to fetch from it.
try {
const automod = require('./src/automod');
if (!automod.previewAllowed(url)) {
return res.status(403).json({ error: 'Link previews are not enabled for that domain' });
}
} catch { /* automod unavailable — fall through rather than break previews */ }
// Use a real browser UA — many sites (Twitter/X, Instagram, etc.) serve
// JS-only pages to unknown bots, omitting the OG meta tags we need.
const PREVIEW_UA = 'Mozilla/5.0 (compatible; HavenBot/2.1; +https://github.com/ancsemi/Haven)';
try {
let data = null;
// ── Site-specific handlers ───────────────────────────
// Native twitter.com / x.com — their HTML requires JS rendering so the generic
// scraper gets blank OG tags. The fxtwitter public JSON API returns structured
// post data (author, avatar, text, media, engagement counts) with no auth, so
// the client can render a rich social card matching the mobile app's embeds.
// NOTE: fxtwitter / vxtwitter / fixupx proxy URLs are NOT matched here; they
// serve their own OG-enriched HTML and fall through to the generic scraper.
const twitterMatch = url.match(/^https?:\/\/(?:(?:www\.|mobile\.)?(?:twitter|x)\.com)\/([A-Za-z0-9_]{1,20})\/status(?:es)?\/(\d+)/i);
if (twitterMatch) {
try {
const fxApi = await fetch(
`https://api.fxtwitter.com/${twitterMatch[1]}/status/${twitterMatch[2]}`,
{ signal: AbortSignal.timeout(6000), headers: { 'User-Agent': PREVIEW_UA } }
);
if (fxApi.ok) {
const tw = (await fxApi.json())?.tweet;
if (tw) {
const a = tw.author || {};
const photos = (tw.media?.photos || []).map(p => p.url).filter(Boolean);
const vid = tw.media?.videos?.[0] || null;
const text = tw.text || '';
data = {
kind: 'twitter',
siteName: 'X / Twitter',
accentColor: '#1d9bf0',
author: a.name || null,
handle: a.screen_name ? `@${a.screen_name}` : null,
avatar: a.avatar_url || null,
title: a.name ? `${a.name} on X` : 'X / Twitter',
text: text || null,
description: text.slice(0, 280) || null,
image: vid?.thumbnail_url || photos[0] || null,
images: photos.length >= 2 ? photos.slice(0, 4) : undefined,
video: vid?.url || null,
videoType: vid?.url ? 'video/mp4' : undefined,
stats: { replies: tw.replies ?? -1, reposts: tw.retweets ?? -1, likes: tw.likes ?? -1, views: tw.views ?? -1 },
url
};
}
}
} catch { /* fall through to fxtwitter OG scrape / generic */ }
}
// ── fxtwitter / vxtwitter / fixupx fallback for native Twitter/X links ──
// If the oEmbed handler above didn't fire (non-matching URL) or failed,
// and the URL is a native twitter.com/x.com link, try fxtwitter as an
// OG-enriched proxy. fxtwitter serves bot-friendly HTML with OG tags.
if (!data && /^https?:\/\/(?:(?:www\.|mobile\.)?(?:twitter|x)\.com)\/\w+\/status\/\d+/i.test(url)) {
try {
const fxUrl = url.replace(/^https?:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com/i, 'https://fxtwitter.com');
const fxResp = await fetch(fxUrl, {
signal: AbortSignal.timeout(6000),
headers: { 'User-Agent': PREVIEW_UA, 'Accept': 'text/html' },
redirect: 'manual'
});
if (fxResp.ok) {
const fxHtml = (await fxResp.text()).slice(0, PREVIEW_MAX_SIZE);
const fxMeta = (prop) => {
const r1 = new RegExp(`<meta[^>]*?(?:property|name)=["']${prop}["'][^>]*?content=["']([^"']+)["']`, 'is');
const r2 = new RegExp(`<meta[^>]*?content=["']([^"']+)["'][^>]*?(?:property|name)=["']${prop}["']`, 'is');
const m = fxHtml.match(r1) || fxHtml.match(r2);
return m ? decodeHtmlEntities(m[1].trim()) : null;
};
const fxTitle = fxMeta('og:title') || fxMeta('twitter:title');
const fxDesc = fxMeta('og:description') || fxMeta('twitter:description');
const fxImg = fxMeta('og:image') || fxMeta('twitter:image');
if (fxTitle || fxDesc) {
data = {
title: fxTitle,
description: fxDesc,
image: fxImg,
siteName: fxMeta('og:site_name') || 'X',
url
};
}
}
} catch { /* fxtwitter fallback failed — continue to generic scrape */ }
}
// ── Reddit — serves no OG tags to unknown bots; use JSON API instead ──
if (!data && /^https?:\/\/(?:(?:www|old|new)\.)?reddit\.com\/r\/[\w]+\/comments\/[\w]+/i.test(url)) {
try {
// Reddit's .json endpoint works with any User-Agent
const jsonUrl = url.replace(/\/?(?:\?.*)?$/, '/.json');
const rResp = await fetch(jsonUrl, {
signal: AbortSignal.timeout(6000),
headers: { 'User-Agent': PREVIEW_UA }
});
if (rResp.ok) {
const rJson = await rResp.json();
const post = rJson?.[0]?.data?.children?.[0]?.data;
if (post) {
const redTitle = `${post.subreddit_name_prefixed || 'Reddit'}: ${post.title || ''}`;
let redImage = null;
let redImages;
if (post.is_gallery && post.media_metadata) {
// Gallery post — collect up to 4 preview images
const imgs = Object.values(post.media_metadata)
.filter(m => m.status === 'valid' && m.s?.u)
.map(m => decodeHtmlEntities(m.s.u))
.slice(0, 4);
if (imgs.length >= 2) redImages = imgs;
redImage = imgs[0] || null;
} else if (post.preview?.images?.[0]?.source?.url) {
redImage = decodeHtmlEntities(post.preview.images[0].source.url);
} else if (post.thumbnail && post.thumbnail !== 'self' && post.thumbnail !== 'default' && post.thumbnail !== 'nsfw' && post.thumbnail !== 'spoiler') {
redImage = post.thumbnail;
}
data = {
title: redTitle,
description: post.selftext ? post.selftext.slice(0, 280) : null,
image: redImage,
images: redImages,
siteName: 'Reddit',
url
};
}
}
} catch { /* Reddit JSON fallback failed — continue to generic scrape */ }
}
// ── Pixiv — blocks bots for HTML but provides an oEmbed API ────────
if (!data && /^https?:\/\/(?:www\.)?pixiv\.net\/(?:en\/)?artworks\/\d+/i.test(url)) {
try {
const poEmbed = await fetch(
`https://embed.pixiv.net/oembed.php?url=${encodeURIComponent(url)}&format=json`,
{ signal: AbortSignal.timeout(6000), headers: { 'User-Agent': PREVIEW_UA } }
);
if (poEmbed.ok) {
const oj = await poEmbed.json();
data = {
title: oj.title || null,
description: oj.author_name ? `by ${oj.author_name}` : null,
image: oj.thumbnail_url || null,
siteName: 'pixiv',
url
};
}
} catch { /* fall through to generic scrape */ }
}
// ── Bluesky — HTML is client-rendered (blank OG tags); the public AT Protocol
// app view returns structured post data with no auth. Resolve the handle to a
// DID when needed, then hydrate the post and pull author / text / media. ──
if (!data && /^https?:\/\/bsky\.app\/profile\/[^/]+\/post\/[A-Za-z0-9]+/i.test(url)) {
try {
const m = url.match(/^https?:\/\/bsky\.app\/profile\/([^/?#]+)\/post\/([A-Za-z0-9]+)/i);
let did = m[1];
if (!did.startsWith('did:')) {
const rh = await fetch(
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(did)}`,
{ signal: AbortSignal.timeout(6000), headers: { 'User-Agent': PREVIEW_UA } }
);
if (rh.ok) did = (await rh.json()).did || did;
}
if (did.startsWith('did:')) {
const atUri = `at://${did}/app.bsky.feed.post/${m[2]}`;
const pResp = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}`,
{ signal: AbortSignal.timeout(6000), headers: { 'User-Agent': PREVIEW_UA } }
);
if (pResp.ok) {
const post = (await pResp.json())?.posts?.[0];
if (post) {
const author = post.author || {};
const name = author.displayName || author.handle || 'Bluesky';
const embed = post.embed || {};
// recordWithMedia nests the real media one level down under .media
const media = (embed.$type || '').startsWith('app.bsky.embed.recordWithMedia') ? (embed.media || {}) : embed;
const mType = media.$type || '';
let image = null, images;
if (mType.startsWith('app.bsky.embed.images')) {
const imgs = (media.images || []).map(i => i.fullsize).filter(Boolean);
if (imgs.length >= 2) images = imgs.slice(0, 4);
image = imgs[0] || null;
} else if (mType.startsWith('app.bsky.embed.video')) {
image = media.thumbnail || null;
} else if (mType.startsWith('app.bsky.embed.external')) {
image = media.external?.thumb || null;
}
const text = post.record?.text || '';
data = {
kind: 'bsky',
siteName: 'Bluesky',
accentColor: '#0085ff',
author: name,
handle: author.handle ? `@${author.handle}` : null,
avatar: author.avatar || null,
title: `${name} on Bluesky`,
text: text || null,
description: text.slice(0, 280) || null,
image,
images,
video: mType.startsWith('app.bsky.embed.video') ? url : null,
stats: { replies: post.replyCount ?? -1, reposts: post.repostCount ?? -1, likes: post.likeCount ?? -1, views: -1 },
url
};
}
}
}
} catch { /* Bluesky app view failed — continue to generic scrape */ }
}
// ── Generic OG scrape (manual redirect following with SSRF checks) ──
if (!data) {
let currentUrl = url;
let resp;
const MAX_REDIRECTS = 5;
for (let i = 0; i <= MAX_REDIRECTS; i++) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
resp = await fetch(currentUrl, {
signal: controller.signal,
headers: {
'User-Agent': PREVIEW_UA,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9'
},
redirect: 'manual' // handle redirects manually to re-check SSRF
});
clearTimeout(timeout);
// If redirect, validate the new URL before following
if ([301, 302, 303, 307, 308].includes(resp.status)) {
const location = resp.headers.get('location');
if (!location) break;
// Resolve relative redirects
const nextUrl = new URL(location, currentUrl).href;
try {
await validateUrlSafe(nextUrl);
} catch {
// Redirect target is private/internal — abort (SSRF protection)
return res.json({ title: null, description: null, image: null, siteName: null });
}
currentUrl = nextUrl;
continue;
}
break; // not a redirect, use this response
}
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
linkPreviewCache.set(url, { data: { title: null, description: null, image: null, siteName: null }, ts: Date.now() });
return res.json({ title: null, description: null, image: null, siteName: null });
}
const html = await resp.text();
const chunk = html.slice(0, PREVIEW_MAX_SIZE);
// Regex helper — handles attributes spanning multiple lines and both
// orderings: property before content, and content before property.
// Decodes HTML entities so image URLs with &amp; etc. work correctly.
const getMetaContent = (property) => {
const re1 = new RegExp(`<meta[^>]*?(?:property|name)=["']${property}["'][^>]*?content=["']([^"']+)["']`, 'is');
const re2 = new RegExp(`<meta[^>]*?content=["']([^"']+)["'][^>]*?(?:property|name)=["']${property}["']`, 'is');
const m = chunk.match(re1) || chunk.match(re2);
return m ? decodeHtmlEntities(m[1].trim()) : null;
};
// Returns ALL values for a given OG property (e.g. multiple og:image tags
// for tweet galleries or reddit image galleries). Deduped, max 4 results.
// Decodes HTML entities in each value.
const getAllMetaContent = (property) => {
const seen = new Set();
const re1 = new RegExp(`<meta[^>]*?(?:property|name)=["']${property}["'][^>]*?content=["']([^"']+)["']`, 'gi');
const re2 = new RegExp(`<meta[^>]*?content=["']([^"']+)["'][^>]*?(?:property|name)=["']${property}["']`, 'gi');
let m;
while ((m = re1.exec(chunk)) !== null) seen.add(decodeHtmlEntities(m[1].trim()));
while ((m = re2.exec(chunk)) !== null) seen.add(decodeHtmlEntities(m[1].trim()));
return [...seen].slice(0, 4);
};
const titleTag = chunk.match(/<title[^>]*>([^<]+)<\/title>/i);
const ogImages = getAllMetaContent('og:image');
// Extract og:video for inline video embeds (MP4, WebM)
const ogVideo = getMetaContent('og:video') || getMetaContent('og:video:url') || getMetaContent('og:video:secure_url');
const ogVideoType = getMetaContent('og:video:type') || '';
// Only embed direct video files (not Flash, iframes, etc.)
const isEmbeddableVideo = ogVideo && (
/^video\/(mp4|webm|ogg)$/i.test(ogVideoType) ||
/\.(mp4|webm|ogg)(\?[^#]*)?$/i.test(ogVideo)
);
data = {
title: getMetaContent('og:title') || getMetaContent('twitter:title') || (titleTag ? titleTag[1].trim() : null),
description: getMetaContent('og:description') || getMetaContent('twitter:description') || getMetaContent('description'),
image: ogImages[0] || getMetaContent('twitter:image'),
images: ogImages.length >= 2 ? ogImages : undefined,
video: isEmbeddableVideo ? ogVideo : undefined,
videoType: isEmbeddableVideo ? (ogVideoType || 'video/mp4') : undefined,
siteName: getMetaContent('og:site_name') || parsed.hostname,
url: getMetaContent('og:url') || url
};
// oEmbed autodiscovery — if OG tags came back empty and the page advertises a
// JSON oEmbed endpoint, use it. This future-proofs support for any oEmbed-compatible
// site without needing a dedicated handler.
if (!data.title && !data.image) {
const oembedHref =
chunk.match(/<link[^>]*?type=["']application\/json\+oembed["'][^>]*?href=["']([^"']+)["']/i) ||
chunk.match(/<link[^>]*?href=["']([^"']+)["'][^>]*?type=["']application\/json\+oembed["']/i);
if (oembedHref) {
try {
const oembedEndpoint = new URL(oembedHref[1], currentUrl).href;
await validateUrlSafe(oembedEndpoint);
const oResp = await fetch(oembedEndpoint, {
signal: AbortSignal.timeout(5000),
headers: { 'User-Agent': PREVIEW_UA }
});
if (oResp.ok) {
const oj = await oResp.json();
data.title = data.title || oj.title || null;
data.image = data.image || oj.thumbnail_url || null;
if (!data.siteName || data.siteName === parsed.hostname) {
data.siteName = oj.provider_name || data.siteName;
}
}
} catch { /* autodiscovery failed — keep OG data as-is */ }
}
}
} else {
// Twitter oEmbed succeeded — try a quick scrape for the image only.
// First try fxtwitter (bot-friendly proxy), then fall back to the original URL.
const imageSource = /^https?:\/\/(?:(?:www\.|mobile\.)?(?:twitter|x)\.com)\/\w+\/status\/\d+/i.test(url)
? url.replace(/^https?:\/\/(?:www\.|mobile\.)?(?:twitter|x)\.com/i, 'https://fxtwitter.com')
: url;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(imageSource, {
signal: controller.signal,
headers: { 'User-Agent': PREVIEW_UA, 'Accept': 'text/html' },
redirect: 'manual' // no blind redirect following
});
clearTimeout(timeout);
// Only scrape if we got a direct 200 (no redirect chasing for image-only pass)
if (resp.status === 200) {
const html = (await resp.text()).slice(0, PREVIEW_MAX_SIZE);
const imgMatch = html.match(/<meta[^>]*?(?:property|name)=["'](?:og:image|twitter:image)["'][^>]*?content=["']([^"']+)["']/is)
|| html.match(/<meta[^>]*?content=["']([^"']+)["'][^>]*?(?:property|name)=["'](?:og:image|twitter:image)["']/is);
if (imgMatch) data.image = decodeHtmlEntities(imgMatch[1].trim());
}
} catch { /* image is optional */ }
}
linkPreviewCache.set(url, { data, ts: Date.now() });
// Prune old cache entries if over 500
if (linkPreviewCache.size > 500) {
const now = Date.now();
for (const [k, v] of linkPreviewCache) {
if (now - v.ts > PREVIEW_CACHE_TTL) linkPreviewCache.delete(k);
}
}
res.json(data);
} catch {
res.json({ title: null, description: null, image: null, siteName: null });
}
});
// ── 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');
const leaderboard = getDb().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 });
});
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() : '';
const score = Number(req.body.score);
if (!game || !/^[a-z0-9_-]{1,32}$/.test(game)) return res.status(400).json({ error: 'Invalid game id' });
if (!Number.isInteger(score) || score < 0) 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' });
}
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'
).get(token);
if (!webhook) {
return res.status(404).json({ error: 'Webhook not found or inactive' });
}
const content = typeof req.body.content === 'string' ? sanitizeText(req.body.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' ? sanitizeText(req.body.username.trim().slice(0, 32)) : webhook.name;
let avatarUrl = webhook.avatar_url;
if (typeof req.body.avatar_url === 'string') {
const trimmed = req.body.avatar_url.trim().slice(0, 512);
avatarUrl = /^https?:\/\//i.test(trimmed) ? trimmed : null;
}
// 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 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,
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 = {
id: r.id,
content: (r.content || '').slice(0, 200),
username: r.is_webhook ? `[BOT] ${r.webhook_username || 'Bot'}` : (r.username || 'Unknown')
};
}
} catch { /* best-effort */ }
}
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
};
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) VALUES (?, ?, ?, 1, ?, ?, ?)'
).run(webhook.channel_id, null, content, username, avatarUrl || null, replyTo);
message.id = result.lastInsertRowid;
// Broadcast to all clients in this channel
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 });
});
// ── 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 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');
// sniffImageType returns the MIME type from a buffer's magic bytes, or null for
// anything that isn't one of the accepted image formats.
function sniffImageType(buf) {
if (!buf || buf.length < 12) return null;
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'image/jpeg';
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return 'image/png';
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif';
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'image/webp';
return null;
}
app.post('/api/webhooks/listening/:token', listeningLimiter, (req, res) => {
listeningUpload(req, res, (err) => {
if (err) return res.status(400).json({ error: 'Invalid upload' });
const { token } = req.params;
if (!token || typeof token !== 'string' || token.length !== 64) {
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' });
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, (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 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' });
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' });
}
// Move any uploaded attachments to the deleted folder
const uploadRe = UPLOAD_PATH_RE;
let m;
while ((m = uploadRe.exec(msg.content || '')) !== null) {
moveUploadToDeleted(m[1], uploadDir);
}
// 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 ─────────
app.delete('/api/webhooks/:token/messages', webhookLimiter, (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' });
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
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 });
}
try {
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
function getModUser(req, permission) {
const token = req.headers.authorization?.split(' ')[1];
const user = token ? verifyToken(token) : null;
if (!user) return { error: 'Unauthorized', status: 401 };
if (!verifyAdminFromDb(user) && !userHasPermission(user.id, permission)) {
return { error: 'Insufficient permissions', status: 403 };
}
return { user };
}
// 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 = ?').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' });
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}`);
}
}
}
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' });
const safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : '';
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)' });
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/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' });
const durationMs = Number.isInteger(duration) && duration > 0 ? duration * 60 * 1000 : 10 * 60 * 1000;
const expiresAt = new Date(Date.now() + durationMs).toISOString();
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', { 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)' });
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/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
`).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;
}
// 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' });
const channel = db.prepare('SELECT id FROM channels WHERE code = ?').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, 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' });
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}`);
}
}
}
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 safeReason = typeof reason === 'string' ? reason.trim().slice(0, 200) : '';
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)' });
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 durationMs = Number.isInteger(duration) && duration > 0 ? duration * 60 * 1000 : 10 * 60 * 1000;
const expiresAt = new Date(Date.now() + durationMs).toISOString();
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)' });
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)
app.get('/api/bot-commands', (req, res) => {
const { getDb } = require('./src/database');
const rows = getDb().prepare(`
SELECT bc.command, bc.description, bc.subcommands_json, w.name as bot_name
FROM bot_commands bc
JOIN webhooks w ON bc.webhook_id = w.id
WHERE w.is_active = 1
`).all();
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'
});
}
continue;
}
commands.push({
command: row.command,
description: row.description || '',
bot_name: row.bot_name || 'Bot'
});
}
res.json({ commands });
});
// ═══════════════════════════════════════════════════════════
// DISCORD IMPORT — upload, preview, execute
// ═══════════════════════════════════════════════════════════
const os = require('os');
const { parseDiscordExport } = require('./src/importDiscord');
// 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 },
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'));
}
});
// ── Step 1: Upload & parse → return preview ──────────────
app.post('/api/import/discord/upload', uploadLimiter, uploadDiskGuard, (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' });
importUpload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const result = parseDiscordExport(req.file.path);
// Save parsed data to temp so the execute step can read it
const importId = crypto.randomBytes(16).toString('hex');
const tempPath = path.join(os.tmpdir(), `haven-import-${importId}.json`);
fs.writeFileSync(tempPath, JSON.stringify(result));
// Clean up the uploaded raw file
try { fs.unlinkSync(req.file.path); } catch {}
// Return preview (channel list + counts — NOT the full messages)
res.json({
importId,
format: result.format,
serverName: result.serverName,
channels: result.channels.map(c => ({
discordId: c.discordId,
name: c.name,
topic: c.topic,
category: c.category,
messageCount: c.messageCount
})),
totalMessages: result.channels.reduce((sum, c) => sum + c.messageCount, 0)
});
} catch (parseErr) {
try { fs.unlinkSync(req.file.path); } catch {}
res.status(400).json({ error: parseErr.message });
}
});
});
// ── Discord Direct Connect — pull messages straight from Discord's API ──
const DISCORD_API = 'https://discord.com/api/v10';
async function discordApiFetch(endpoint, userToken, retries = 2) {
const resp = await fetch(`${DISCORD_API}${endpoint}`, {
headers: { Authorization: userToken }
});
if (resp.status === 401) throw new Error('Invalid or expired Discord token');
if (resp.status === 403) throw new Error('Access denied — check token permissions');
if (resp.status === 429 && retries > 0) {
const wait = parseFloat(resp.headers.get('retry-after') || '3');
await new Promise(r => setTimeout(r, wait * 1000));
return discordApiFetch(endpoint, userToken, retries - 1);
}
if (!resp.ok) throw new Error(`Discord API error ${resp.status}`);
return resp.json();
}
// Step A: validate token → list servers
app.post('/api/import/discord/connect', 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' });
const { discordToken } = req.body;
if (!discordToken || typeof discordToken !== 'string') {
return res.status(400).json({ error: 'Discord token required' });
}
try {
const me = await discordApiFetch('/users/@me', discordToken);
const guilds = await discordApiFetch('/users/@me/guilds?limit=200', discordToken);
res.json({
user: { username: me.global_name || me.username },
guilds: guilds.map(g => ({ id: g.id, name: g.name, icon: g.icon }))
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Step B: list text channels, announcement channels, forums, and threads for a guild
app.post('/api/import/discord/guild-channels', 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' });
const { discordToken, guildId } = req.body;
if (!discordToken || !guildId) return res.status(400).json({ error: 'Missing params' });
try {
const allChannels = await discordApiFetch(`/guilds/${guildId}/channels`, discordToken);
// Build category map
const categories = {};
allChannels.filter(c => c.type === 4).forEach(c => { categories[c.id] = c.name; });
// Text (0), Announcement (5), Forum (15), Media (16) — all contain readable content
const textTypes = new Set([0, 5, 15, 16]);
const channelsList = allChannels
.filter(c => textTypes.has(c.type))
.sort((a, b) => a.position - b.position)
.map(c => ({
id: c.id,
name: c.name,
topic: c.topic || '',
category: (c.parent_id && categories[c.parent_id]) || null,
type: c.type === 5 ? 'announcement' : c.type === 15 ? 'forum' : c.type === 16 ? 'media' : 'text',
// Forum tags (available on type 15 and 16)
tags: Array.isArray(c.available_tags) ? c.available_tags.map(t => ({ id: t.id, name: t.name })) : []
}));
// Fetch threads (active + archived public)
const threads = [];
// Active threads
try {
const active = await discordApiFetch(`/guilds/${guildId}/threads/active`, discordToken);
if (active.threads) threads.push(...active.threads);
} catch {}
// Archived threads per text/forum/announcement channel (up to 100 per channel)
for (const ch of channelsList) {
try {
const archived = await discordApiFetch(`/channels/${ch.id}/threads/archived/public?limit=100`, discordToken);
if (archived.threads) threads.push(...archived.threads);
} catch {}
await new Promise(r => setTimeout(r, 200));
}
// De-duplicate threads and map to entries
const seen = new Set();
const threadEntries = [];
for (const t of threads) {
if (seen.has(t.id)) continue;
seen.add(t.id);
// Find parent channel
const parent = channelsList.find(c => c.id === t.parent_id);
const parentName = parent ? parent.name : null;
// Resolve applied forum tags
let tagNames = [];
if (Array.isArray(t.applied_tags) && parent && parent.tags.length) {
tagNames = t.applied_tags
.map(tid => parent.tags.find(tag => tag.id === tid))
.filter(Boolean)
.map(tag => tag.name);
}
threadEntries.push({
id: t.id,
name: t.name,
topic: '',
category: (parent && parent.category) || null,
type: 'thread',
parentId: t.parent_id,
parentName,
tags: tagNames
});
}
res.json({ channels: channelsList, threads: threadEntries });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Step C: fetch all messages from selected channels → save temp → return preview
app.post('/api/import/discord/fetch', 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' });
const { discordToken, guildName, channels: selected } = req.body;
if (!discordToken || !Array.isArray(selected) || !selected.length) {
return res.status(400).json({ error: 'Missing params' });
}
try {
const result = {
format: 'Discord Direct',
serverName: guildName || 'Discord Import',
channels: []
};
for (const ch of selected) {
const messages = [];
let before = null, batch;
do {
let ep = `/channels/${ch.id}/messages?limit=100`;
if (before) ep += `&before=${before}`;
batch = await discordApiFetch(ep, discordToken);
for (const msg of batch) {
if (msg.type !== 0 && msg.type !== 19) continue; // Default + Reply only
let content = msg.content || '';
if (Array.isArray(msg.attachments)) {
for (const a of msg.attachments) {
content += `\n📎 ${a.url ? '[' + a.filename + '](' + a.url + ')' : a.filename}`;
}
}
if (Array.isArray(msg.embeds)) {
for (const e of msg.embeds) {
if (e.title) content += `\n🔗 **${e.title}**`;
if (e.description) content += `\n${e.description}`;
if (e.url && !content.includes(e.url)) content += `\n${e.url}`;
}
}
content = content.trim();
if (!content) continue;
messages.push({
discordId: msg.id,
author: msg.author?.global_name || msg.author?.username || 'Unknown',
authorId: msg.author?.id || null,
authorAvatar: msg.author?.avatar
? `https://cdn.discordapp.com/avatars/${msg.author.id}/${msg.author.avatar}.png?size=64`
: null,
isBot: msg.author?.bot || false,
content,
timestamp: msg.timestamp,
isPinned: msg.pinned || false,
reactions: (msg.reactions || []).map(r => ({
emoji: r.emoji?.name || '❓',
count: r.count || 1
})),
replyTo: msg.message_reference?.message_id || null
});
}
if (batch.length > 0) before = batch[batch.length - 1].id;
await new Promise(r => setTimeout(r, 300)); // respect rate limits
} while (batch.length === 100);
result.channels.push({
discordId: ch.id,
name: ch.name,
topic: ch.topic || '',
category: ch.category || null,
messageCount: messages.length,
messages
});
}
const importId = crypto.randomBytes(16).toString('hex');
const tempPath = path.join(os.tmpdir(), `haven-import-${importId}.json`);
fs.writeFileSync(tempPath, JSON.stringify(result));
res.json({
importId,
format: result.format,
serverName: result.serverName,
channels: result.channels.map(c => ({
discordId: c.discordId,
name: c.name,
topic: c.topic,
category: c.category,
messageCount: c.messageCount
})),
totalMessages: result.channels.reduce((sum, c) => sum + c.messageCount, 0)
});
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// ── Periodic cleanup of orphaned import temp files (1 hour TTL) ──
function cleanupTempImports() {
try {
const tmpDir = os.tmpdir();
const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour
for (const f of fs.readdirSync(tmpDir)) {
if (!f.startsWith('haven-import-')) continue;
const fp = path.join(tmpDir, f);
try {
const stat = fs.statSync(fp);
if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
} catch {}
}
} catch {}
}
// Run once at startup to clean up any stale files from previous crashes
cleanupTempImports();
setInterval(cleanupTempImports, 15 * 60 * 1000); // then every 15 min
// ── Step 2: Execute the import ───────────────────────────
app.post('/api/import/discord/execute', express.json({ limit: '1mb' }), (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 { importId, selectedChannels } = req.body;
if (!importId || !Array.isArray(selectedChannels) || selectedChannels.length === 0) {
return res.status(400).json({ error: 'Missing importId or selectedChannels' });
}
// Validate importId is hex-only (prevent path traversal)
if (!/^[a-f0-9]{32}$/.test(importId)) {
return res.status(400).json({ error: 'Invalid import ID' });
}
const tempPath = path.join(os.tmpdir(), `haven-import-${importId}.json`);
if (!fs.existsSync(tempPath)) {
return res.status(404).json({ error: 'Import data expired or not found. Please re-upload.' });
}
try {
const data = JSON.parse(fs.readFileSync(tempPath, 'utf-8'));
const { getDb } = require('./src/database');
const db = getDb();
const { generateChannelCode } = require('./src/auth');
const { generateUniqueChannelCode } = require('./src/channelRotation');
const stats = { channelsCreated: 0, channelsReused: 0, messagesImported: 0, messagesSkipped: 0 };
const txn = db.transaction(() => {
for (const sel of selectedChannels) {
// Find channel data by discordId or original name
const channelData = data.channels.find(c =>
(sel.discordId && c.discordId === sel.discordId) ||
c.name === sel.originalName
);
if (!channelData || !channelData.messages) continue;
const channelName = [...(sel.name || channelData.name)].slice(0, 50).join('');
const code = generateUniqueChannelCode(db, generateChannelCode);
// Reuse an existing Haven channel if it was created from the same Discord channel.
// This makes re-importing (or importing a second overlapping export) idempotent —
// new messages are appended, duplicates are skipped, and native Haven messages are untouched.
let channelId;
const discordChannelId = channelData.discordId || null;
if (discordChannelId) {
const existing = db.prepare('SELECT id FROM channels WHERE discord_channel_id = ?').get(discordChannelId);
if (existing) {
channelId = existing.id;
stats.channelsReused++;
}
}
if (!channelId) {
// Create the Haven channel
const chResult = db.prepare(
'INSERT INTO channels (name, code, created_by, topic, discord_channel_id) VALUES (?, ?, ?, ?, ?)'
).run(channelName, code, user.id, channelData.topic || '', discordChannelId);
channelId = chResult.lastInsertRowid;
// Auto-join the importing admin
db.prepare('INSERT OR IGNORE INTO channel_members (channel_id, user_id) VALUES (?, ?)').run(channelId, user.id);
stats.channelsCreated++;
}
// Sort messages chronologically
const sorted = channelData.messages.slice().sort(
(a, b) => new Date(a.timestamp) - new Date(b.timestamp)
);
// Discord message ID → Haven message ID (for reply threading)
const idMap = {};
const insertMsg = db.prepare(`
INSERT OR IGNORE INTO messages (channel_id, user_id, content, created_at, webhook_username, webhook_avatar, is_webhook, imported_from, reply_to, discord_message_id)
VALUES (?, ?, ?, ?, ?, ?, 0, 'discord', ?, ?)
`);
const lookupByDiscordId = db.prepare('SELECT id FROM messages WHERE discord_message_id = ?');
for (const msg of sorted) {
const content = (msg.content || '').trim();
if (!content) continue;
// Resolve reply to an already-imported Haven message
let replyTo = null;
if (msg.replyTo && idMap[msg.replyTo]) {
replyTo = idMap[msg.replyTo];
}
// Normalize timestamp to SQLite-friendly format
let ts;
try {
ts = new Date(msg.timestamp).toISOString().replace('T', ' ').replace('Z', '');
} catch {
ts = msg.timestamp;
}
const result = insertMsg.run(
channelId, user.id, content, ts, msg.author || 'Unknown', msg.authorAvatar || null, replyTo, msg.discordId || null
);
if (result.changes === 0) {
// Duplicate Discord message — resolve ID for reply threading and skip
if (msg.discordId) {
const existing = lookupByDiscordId.get(msg.discordId);
if (existing) idMap[msg.discordId] = existing.id;
}
stats.messagesSkipped++;
continue;
}
if (msg.discordId) {
idMap[msg.discordId] = result.lastInsertRowid;
}
stats.messagesImported++;
// Pin if flagged
if (msg.isPinned) {
try {
db.prepare('INSERT INTO pinned_messages (message_id, channel_id, pinned_by) VALUES (?, ?, ?)')
.run(result.lastInsertRowid, channelId, user.id);
} catch {}
}
// Import reactions
if (Array.isArray(msg.reactions)) {
for (const r of msg.reactions) {
if (!r.emoji) continue;
try {
db.prepare('INSERT OR IGNORE INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)')
.run(result.lastInsertRowid, user.id, r.emoji);
} catch {}
}
}
}
}
});
txn();
// Clean up temp file
try { fs.unlinkSync(tempPath); } catch {}
res.json({ success: true, ...stats });
} catch (err) {
console.error('Import execute error:', err);
res.status(500).json({ error: 'Import failed: ' + err.message });
}
});
// Create HTTP or HTTPS server
let server;
// Resolve SSL paths: if set in .env resolve relative to DATA_DIR, otherwise auto-detect
let sslCert = process.env.SSL_CERT_PATH;
let sslKey = process.env.SSL_KEY_PATH;
// If not explicitly configured, check if the startup scripts generated certs
if (!sslCert && !sslKey) {
const autoCert = path.join(CERTS_DIR, 'cert.pem');
const autoKey = path.join(CERTS_DIR, 'key.pem');
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 forceHttp = (process.env.FORCE_HTTP || '').toLowerCase() === 'true';
const useSSL = sslCert && sslKey && !forceHttp;
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');
// 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);
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.
try { seedStarterStickers(); } catch {}
// 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 {}
// ── 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 {}
} 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 {}
initFcm(DATA_DIR);
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;
// ── 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,
insertHavenMessage: ({ channelId, channelCode, username, avatarUrl, content }) => {
try {
const result = db.prepare(
'INSERT INTO messages (channel_id, user_id, content, is_webhook, webhook_username, webhook_avatar) VALUES (?, ?, ?, 1, ?, ?)'
).run(channelId, null, content, username, avatarUrl || null);
io.to(`channel:${channelCode}`).emit('new-message', {
channelCode,
message: {
id: result.lastInsertRowid,
content,
created_at: new Date().toISOString(),
username: `[BOT] ${username}`,
user_id: null,
avatar: avatarUrl || null,
avatar_shape: 'square',
reply_to: null,
replyContext: null,
reactions: [],
is_webhook: true,
webhook_name: username,
from_discord: true
}
});
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"
).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);
}
}
});
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 { /* exit cleanup */ } });
}
// ── Auto-cleanup interval (runs every 15 minutes) ───────
function runAutoCleanup() {
try {
const getSetting = (key) => {
const row = db.prepare('SELECT value FROM server_settings WHERE key = ?').get(key);
return row ? row.value : null;
};
const enabled = getSetting('cleanup_enabled');
if (enabled !== 'true') return;
const maxAgeDays = parseInt(getSetting('cleanup_max_age_days') || '0');
const maxSizeMb = parseInt(getSetting('cleanup_max_size_mb') || '0');
let totalDeleted = 0;
// Pull every /uploads/ attachment path out of a message body. Reuses the
// shared UPLOAD_PATH_RE (global regex — reset lastIndex before each use).
const extractUploadRelPaths = (content) => {
const out = [];
if (typeof content !== 'string' || !content) return out;
UPLOAD_PATH_RE.lastIndex = 0;
let m;
while ((m = UPLOAD_PATH_RE.exec(content)) !== null) {
if (isSafeUploadRelPath(m[1])) out.push(m[1]);
}
return out;
};
// Relocate the given attachment paths into deleted-attachments, but only
// if no surviving message still references them (a file can be linked from
// more than one message via copy/paste). This is the ONLY way auto-cleanup
// removes files: it follows the messages it deletes. It never scans the
// uploads/ directory directly, so avatars, persona avatars, custom emojis,
// soundboard sounds, stickers, and the server icon are never at risk (#5423).
const relocateOrphanAttachments = (relPaths) => {
for (const rel of relPaths) {
try {
const like = '%/uploads/' + rel.replace(/[\\%_]/g, '\\$&') + '%';
const still = db.prepare(
"SELECT 1 FROM messages WHERE content LIKE ? ESCAPE '\\' LIMIT 1"
).get(like);
if (!still) moveUploadToDeleted(rel, UPLOADS_DIR);
} catch { /* best-effort */ }
}
};
// 1. Delete messages older than N days (skip archived/pinned messages
// and exempt channels)
if (maxAgeDays > 0) {
// Capture the attachments of the messages we're about to delete first,
// so their files can follow them into deleted-attachments afterward.
const doomed = db.prepare(
"SELECT content FROM messages WHERE created_at < datetime('now', ?) AND is_archived = 0 AND id NOT IN (SELECT message_id FROM pinned_messages) AND channel_id NOT IN (SELECT id FROM channels WHERE cleanup_exempt = 1)"
).all(`-${maxAgeDays} days`);
const doomedAttachments = new Set();
for (const row of doomed) for (const p of extractUploadRelPaths(row.content)) doomedAttachments.add(p);
// Delete reactions for old messages first
db.prepare(`
DELETE FROM reactions WHERE message_id IN (
SELECT id FROM messages WHERE created_at < datetime('now', ?) AND is_archived = 0
AND id NOT IN (SELECT message_id FROM pinned_messages)
AND channel_id NOT IN (SELECT id FROM channels WHERE cleanup_exempt = 1)
)
`).run(`-${maxAgeDays} days`);
const result = db.prepare(
"DELETE FROM messages WHERE created_at < datetime('now', ?) AND is_archived = 0 AND id NOT IN (SELECT message_id FROM pinned_messages) AND channel_id NOT IN (SELECT id FROM channels WHERE cleanup_exempt = 1)"
).run(`-${maxAgeDays} days`);
totalDeleted += result.changes;
relocateOrphanAttachments(doomedAttachments);
}
// 2. If total DB size exceeds maxSizeMb, trim oldest messages (skip
// archived and pinned)
if (maxSizeMb > 0) {
const dbPath = DB_PATH;
const stats = require('fs').statSync(dbPath);
const sizeMb = stats.size / (1024 * 1024);
if (sizeMb > maxSizeMb) {
// Delete oldest 10% of non-archived, non-pinned messages to bring
// size down.
const totalCount = db.prepare('SELECT COUNT(*) as cnt FROM messages WHERE is_archived = 0 AND id NOT IN (SELECT message_id FROM pinned_messages) AND channel_id NOT IN (SELECT id FROM channels WHERE cleanup_exempt = 1)').get().cnt;
const deleteCount = Math.max(Math.floor(totalCount * 0.1), 100);
const oldestRows = db.prepare(
'SELECT id, content FROM messages WHERE is_archived = 0 AND id NOT IN (SELECT message_id FROM pinned_messages) AND channel_id NOT IN (SELECT id FROM channels WHERE cleanup_exempt = 1) ORDER BY created_at ASC LIMIT ?'
).all(deleteCount);
const oldestIds = oldestRows.map(r => r.id);
const trimmedAttachments = new Set();
for (const row of oldestRows) for (const p of extractUploadRelPaths(row.content)) trimmedAttachments.add(p);
if (oldestIds.length > 0) {
// Chunk deletes to avoid creating extremely long SQL statements
const CHUNK_SIZE = 1000;
for (let i = 0; i < oldestIds.length; i += CHUNK_SIZE) {
const chunk = oldestIds.slice(i, i + CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
db.prepare(`DELETE FROM reactions WHERE message_id IN (${placeholders})`).run(...chunk);
db.prepare(`DELETE FROM messages WHERE id IN (${placeholders})`).run(...chunk);
}
totalDeleted += oldestIds.length;
relocateOrphanAttachments(trimmedAttachments);
}
}
}
// Purge old files from deleted-attachments only. These are former message
// attachments that were relocated here when their message was deleted (by
// the steps above, by single-message deletes, or by the orphan-DM sweep).
//
// We deliberately do NOT scan the main uploads/ directory. That directory
// also holds user avatars, persona avatars, custom emojis, soundboard
// sounds, and the server icon — none of which are posted media. The old
// "delete everything in uploads/ that isn't on a protect-list" approach
// kept silently eating any file type nobody remembered to allow-list
// (persona avatars in #5423, stickers/emojis before that). Auto-cleanup is
// scoped to posts and messages and their attachments — nothing else.
if (maxAgeDays > 0) {
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
const deletedDir = path.join(UPLOADS_DIR, 'deleted-attachments');
if (require('fs').existsSync(deletedDir)) {
let daDeleted = 0;
for (const f of require('fs').readdirSync(deletedDir)) {
try {
const fp = require('path').join(deletedDir, f);
const st = require('fs').statSync(fp);
if (st.isFile() && st.mtimeMs < cutoff) {
require('fs').unlinkSync(fp);
daDeleted++;
}
} catch { /* skip */ }
}
if (daDeleted > 0) {
console.log(`Auto-cleanup: removed ${daDeleted} files from deleted-attachments`);
}
}
}
// 3. (#5282) Orphan-DM sweep — delete any DM channel that has dropped
// below 2 members (one or both participants deleted their account or
// were force-removed). channel_members.user_id has ON DELETE CASCADE
// so the row vanishes when the user does, but the DM channel itself
// is left lingering with stale messages forever; this is the
// "orphaned conversation" issue called out in #5282. Runs regardless
// of cleanup_enabled so the data isn't retained indefinitely.
try {
const orphanRows = db.prepare(`
SELECT c.id, c.code, COUNT(cm.user_id) as member_count
FROM channels c
LEFT JOIN channel_members cm ON cm.channel_id = c.id
WHERE c.is_dm = 1
GROUP BY c.id
HAVING member_count < 2
`).all();
let orphansDeleted = 0;
for (const ch of orphanRows) {
try {
// Move any /uploads/<file> referenced in this DM's messages to
// deleted-attachments first so file cleanup doesn't lose track.
const msgs = db.prepare('SELECT content FROM messages WHERE channel_id = ?').all(ch.id);
const uploadRe = UPLOAD_PATH_RE;
const seen = new Set();
for (const m of msgs) {
if (typeof m.content !== 'string') continue;
uploadRe.lastIndex = 0;
let mm;
while ((mm = uploadRe.exec(m.content)) !== null) {
if (isSafeUploadRelPath(mm[1])) seen.add(mm[1]);
}
}
if (seen.size) {
const deletedDir = path.join(UPLOADS_DIR, 'deleted-attachments');
require('fs').mkdirSync(deletedDir, { recursive: true });
for (const fn of seen) {
moveUploadToDeleted(fn, UPLOADS_DIR);
}
}
// Delete the channel — cascades to messages + read_positions +
// channel_members + reactions etc. via the existing FKs.
db.prepare('DELETE FROM channels WHERE id = ?').run(ch.id);
orphansDeleted++;
} catch (e) {
console.error('[orphan-DM] failed to clean', ch.code, e.message);
}
}
if (orphansDeleted > 0) {
console.log(`🗑️ Auto-cleanup: removed ${orphansDeleted} orphan DM channel(s)`);
}
} catch (e) { /* sweep is best-effort */ }
if (totalDeleted > 0) {
console.log(`🗑️ Auto-cleanup: deleted ${totalDeleted} old messages`);
}
} catch (err) {
console.error('Auto-cleanup error:', err);
}
}
// Run cleanup every 15 minutes
setInterval(runAutoCleanup, 15 * 60 * 1000);
// Also run once at startup (delayed 30s to let DB settle)
setTimeout(runAutoCleanup, 30000);
// Expose globally so socketHandlers can trigger it
global.runAutoCleanup = runAutoCleanup;
// ── Auto-backup (runs hourly, decides per server settings) ───────
// Stored under DATA_DIR/auto-backups. Pruned to keep N most recent.
const AUTO_BACKUP_DIR = path.join(DATA_DIR, 'auto-backups');
function pruneAutoBackups(retain) {
try {
if (!fs.existsSync(AUTO_BACKUP_DIR)) return;
const files = fs.readdirSync(AUTO_BACKUP_DIR)
.filter(f => f.endsWith('.zip') && !f.startsWith('.'))
.map(f => ({ name: f, full: path.join(AUTO_BACKUP_DIR, f), mtime: fs.statSync(path.join(AUTO_BACKUP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
for (const f of files.slice(retain)) {
try { fs.unlinkSync(f.full); } catch {}
}
} catch (err) {
console.error('[AutoBackup] Prune failed:', err);
}
}
async function runAutoBackup() {
try {
const getSetting = (key) => {
const row = db.prepare('SELECT value FROM server_settings WHERE key = ?').get(key);
return row ? row.value : null;
};
if (getSetting('auto_backup_enabled') !== 'true') return;
const intervalH = Math.max(1, parseInt(getSetting('auto_backup_interval_hours') || '24'));
const retain = Math.max(1, Math.min(50, parseInt(getSetting('auto_backup_retention') || '7')));
const sectionsRaw = getSetting('auto_backup_sections') || 'channels,users,settings,messages';
const include = sectionsRaw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const lastRunRaw = getSetting('auto_backup_last_run');
const lastRun = lastRunRaw ? parseInt(lastRunRaw) : 0;
const now = Date.now();
if (lastRun && (now - lastRun) < intervalH * 60 * 60 * 1000) return;
if (!fs.existsSync(AUTO_BACKUP_DIR)) fs.mkdirSync(AUTO_BACKUP_DIR, { recursive: true });
// Stream to a .partial file first so a crash mid-write can't leave a
// truncated .zip that pruning/restore would treat as valid, then rename in.
const tmpOut = path.join(AUTO_BACKUP_DIR, `.partial-${Date.now()}-${Math.random().toString(36).slice(2)}.zip`);
const { filePath, filename } = await buildBackupFile(include, tmpOut);
const outPath = path.join(AUTO_BACKUP_DIR, filename);
fs.renameSync(filePath, outPath);
const sizeMB = (fs.statSync(outPath).size / 1024 / 1024).toFixed(2);
db.prepare("INSERT OR REPLACE INTO server_settings (key, value) VALUES ('auto_backup_last_run', ?)").run(String(now));
pruneAutoBackups(retain);
console.log(`💾 Auto-backup written: ${filename} (${sizeMB} MB)`);
} catch (err) {
console.error('[AutoBackup] Failed:', err);
}
}
// Check hourly whether it's time for an auto-backup. The function itself
// honors the configured interval, so this can be cheap.
setInterval(runAutoBackup, 60 * 60 * 1000);
// First check 60s after boot so it doesn't fight with cleanup or migrations
setTimeout(runAutoBackup, 60000);
// ── Admin: list / download / delete / trigger auto-backups ─────
app.get('/api/admin/auto-backups', (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' });
try {
if (!fs.existsSync(AUTO_BACKUP_DIR)) return res.json({ files: [] });
const files = fs.readdirSync(AUTO_BACKUP_DIR)
.filter(f => f.endsWith('.zip') && !f.startsWith('.'))
.map(f => {
const st = fs.statSync(path.join(AUTO_BACKUP_DIR, f));
return { name: f, size: st.size, mtime: st.mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/admin/auto-backups/:name', (req, res) => {
const token = req.query.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' });
const name = req.params.name;
// Path traversal guard: backups are flat zip files only.
if (!/^[\w.-]+\.zip$/.test(name)) return res.status(400).json({ error: 'Invalid name' });
const full = path.join(AUTO_BACKUP_DIR, name);
if (!fs.existsSync(full) || !full.startsWith(AUTO_BACKUP_DIR)) return res.status(404).json({ error: 'Not found' });
res.download(full, name);
});
app.delete('/api/admin/auto-backups/:name', (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' });
const name = req.params.name;
if (!/^[\w.-]+\.zip$/.test(name)) return res.status(400).json({ error: 'Invalid name' });
const full = path.join(AUTO_BACKUP_DIR, name);
if (!fs.existsSync(full) || !full.startsWith(AUTO_BACKUP_DIR)) return res.status(404).json({ error: 'Not found' });
try { fs.unlinkSync(full); res.json({ ok: true }); }
catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/admin/auto-backups/run-now', (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' });
// Reset last-run so runAutoBackup definitely fires.
try { db.prepare("DELETE FROM server_settings WHERE key = 'auto_backup_last_run'").run(); } catch {}
setImmediate(runAutoBackup);
res.json({ ok: true });
});
// ── Admin: dynamic DNS status + force-refresh ─────────────
// Returns the last DDNS update result (provider, IP, ok/error, timestamp).
// POST forces an immediate update — useful if the user just changed their
// .env or believes the cached IP is stale (ISP rotation, VPN toggle, etc.).
app.get('/api/admin/ddns/status', (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' });
res.json(getDdnsStatus());
});
app.post('/api/admin/ddns/refresh', 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' });
if (!verifyAdminFromDb(user)) return res.status(403).json({ error: 'Admin only' });
try {
const status = await triggerDdnsNow();
res.json(status);
} catch (err) {
res.status(500).json({ error: (err && err.message) || 'Failed to refresh DDNS' });
}
});
// ── Admin: in-app update check + run ─────────────────────
// Detects how Haven was installed and returns the right command (or runs it).
// Docker is intentionally NOT auto-runnable from inside the container — we just
// surface the right command for the operator to run on the host.
function detectInstallMethod() {
const cwd = process.cwd();
const inDocker = fs.existsSync('/.dockerenv') || process.env.HAVEN_IN_DOCKER === 'true';
if (inDocker) return 'docker';
if (fs.existsSync(path.join(cwd, '.git'))) return 'git';
if (process.platform === 'win32' && fs.existsSync(path.join(cwd, 'Install Haven.bat'))) return 'windows-installer';
if (fs.existsSync(path.join(cwd, 'install.sh'))) return 'shell-installer';
return 'manual';
}
function getUpdateInstructions(method) {
switch (method) {
case 'docker': return {
runnable: false,
command: 'docker compose pull && docker compose up -d',
message: 'Update from the host machine: cd into the haven-docker folder and run the command below.',
};
case 'git': return {
runnable: true,
command: 'git pull --ff-only && npm install --omit=dev',
message: 'Pull latest from GitHub and reinstall dependencies. The server will exit after the update so your supervisor (systemd / Docker / installer service) restarts it on the new code.',
};
case 'windows-installer': return {
runnable: true,
command: '"Install Haven.bat" /update',
message: 'Re-run the Windows installer in update mode. The server will exit so the installer can replace files and restart the service.',
};
case 'shell-installer': return {
runnable: true,
command: 'bash install.sh --update',
message: 'Re-run the install script in update mode. The server will exit so the installer can refresh files and restart the service.',
};
default: return {
runnable: false,
message: 'Update method could not be detected. Pull the latest release from https://github.com/ancsemi/Haven/releases and replace your install manually.',
};
}
}
app.get('/api/admin/update/check', async (req, res) => {
const token = req.query.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' });
const currentVersion = require('./package.json').version;
const method = detectInstallMethod();
const instructions = getUpdateInstructions(method);
try {
const r = await fetch('https://api.github.com/repos/ancsemi/Haven/releases/latest', {
headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'haven-update-check' },
});
if (!r.ok) throw new Error(`GitHub HTTP ${r.status}`);
const data = await r.json();
const latestVersion = String(data.tag_name || '').replace(/^v/, '');
const cmp = compareVersions(currentVersion, latestVersion);
res.json({
currentVersion,
latestVersion,
updateAvailable: cmp < 0,
releaseUrl: data.html_url,
releaseNotes: data.body || '',
method,
...instructions,
});
} catch (err) {
res.status(502).json({ error: 'Could not reach GitHub: ' + err.message, currentVersion, method, ...instructions });
}
});
function compareVersions(a, b) {
const pa = String(a).split('.').map(n => parseInt(n) || 0);
const pb = String(b).split('.').map(n => parseInt(n) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
}
return 0;
}
app.post('/api/admin/update/run', (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' });
const method = detectInstallMethod();
const instructions = getUpdateInstructions(method);
if (!instructions.runnable) {
return res.status(400).json({ error: instructions.message, method });
}
// Trigger an auto-backup first so we have a rollback point.
try { db.prepare("DELETE FROM server_settings WHERE key = 'auto_backup_last_run'").run(); } catch {}
try { runAutoBackup(); } catch (err) { console.error('[Update] Pre-update backup failed:', err); }
res.json({ ok: true, method, message: instructions.message });
// Run the update command in a detached child process so the parent can exit cleanly.
const { spawn } = require('child_process');
console.log(`🔄 [Update] Running update command for method=${method}: ${instructions.command}`);
setTimeout(() => {
try {
const child = spawn(instructions.command, {
cwd: process.cwd(),
shell: true,
detached: true,
stdio: 'ignore',
});
child.unref();
} catch (err) {
console.error('[Update] Failed to spawn update command:', err);
}
// Give the child a moment to start, then exit so the supervisor restarts us.
setTimeout(() => {
console.log('🔄 [Update] Exiting so supervisor restarts on new code…');
botAudioManager?.shutdown();
process.exit(0);
}, 1500);
}, 1500);
});
// ── 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 {}
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 {}
}
});
// ── 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`
: `no permission to bind port ${PORT} (ports below 1024 need elevation)`;
console.error(`\n❌ Haven could not start: ${why}`);
console.error(` Stop the other process or change PORT in your .env, then start Haven again.`);
logCrash('Fatal listen error (exiting)', err);
process.exit(1);
}
logCrash('HTTP server error (server kept alive)', err);
});
server.listen(PORT, HOST, () => {
console.log(`
╔══════════════════════════════════════════╗
║ 🏠 HAVEN is running ║
╠══════════════════════════════════════════╣
║ Name: ${(process.env.SERVER_NAME || 'Haven').padEnd(29)}║
║ Local: ${protocol}://localhost:${PORT} ║
║ Network: ${protocol}://YOUR_IP:${PORT} ║
║ Admin: ${(process.env.ADMIN_USERNAME || 'admin').padEnd(29)}║
╚══════════════════════════════════════════╝
`);
// 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); }
});
function gracefulShutdown(signal) {
const ts = new Date().toISOString();
const line = `[${ts}] Graceful shutdown: ${signal}\n`;
try { rotateCrashLogIfNeeded(); fs.appendFileSync(CRASH_LOG, line); } catch {}
console.log(`\n${signal} received — shutting down`);
botAudioManager?.shutdown();
io.close();
server.close(() => process.exit(0));
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));