Files
Haven/server.js
T
ancsemi 415ff761e4 v1.2.0: comprehensive bug hunt + voice UX + pinning + link previews + GIF search
Security fixes:
- Admin username hijack via rename (critical)
- XSS via unescaped quotes in _escapeHtml (critical)
- SSRF protection on /api/link-preview
- API key leak in get-server-settings to non-admins
- Cross-channel reaction removal without verification
- Voice signaling (offer/answer/ICE) without room membership check
- Typing indicator without channel membership check

Bug fixes:
- Voice audio: single MediaStreamSource splits to analyser + gain
- Talking indicator: 300ms hysteresis for smooth detection
- Screen share: SDP rollback, event.streams[0], track.onunmute
- GIF send broken: wrong property names silently dropped all GIFs
- Reconnect recovery: re-emit channel/state events on socket reconnect
- Screen share privacy: stopScreenShare() on viewer close
- Auto-scroll: force flag after message append
- Delete-user FK violation: clean up all related tables
- Delete-channel: remove associated pinned_messages
- Delete-message: remove associated pinned_messages
- LIKE wildcard injection in search-messages

Performance:
- N+1 query in get-messages replaced with 3 batch queries

Features (from prior uncommitted work):
- Voice join/leave audio cues, talking indicators
- Multi-stream screen sharing with tiled grid
- Message pinning (admin-only, 50/channel cap)
- Enhanced markdown: fenced code blocks, blockquotes
- Link previews with OG metadata + 30min cache
- Tenor GIF search with admin-configurable API key

Docs:
- README updated with new features, voice/screen share docs
- CHANGELOG v1.2.0 entry with full details
- Version bumped to 1.2.0
2026-02-11 20:33:29 -05:00

503 lines
20 KiB
JavaScript

// ── Resolve data directory BEFORE loading .env ────────────
const { DATA_DIR, DB_PATH, ENV_PATH, CERTS_DIR, UPLOADS_DIR } = require('./src/paths');
// Bootstrap .env into the data directory if it doesn't exist yet
const fs = require('fs');
const path = require('path');
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 });
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');
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)');
}
const { initDatabase } = require('./src/database');
const { router: authRoutes, authLimiter, verifyToken } = require('./src/auth');
const { setupSocketHandlers } = require('./src/socketHandlers');
const app = express();
// ── Security Headers (helmet) ────────────────────────────
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // inline styles needed for themes
imgSrc: ["'self'", "data:", "blob:", "https://media.tenor.com", "https:"], // https: for link preview OG images
connectSrc: ["'self'", "wss:", "ws:"], // Socket.IO websockets
mediaSrc: ["'self'", "blob:"], // WebRTC audio
fontSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"], // prevent clickjacking
}
},
crossOriginEmbedderPolicy: false, // needed for WebRTC
crossOriginOpenerPolicy: false, // needed for WebRTC
}));
// Disable Express version disclosure
app.disable('x-powered-by');
// ── Body Parsing with size limits ────────────────────────
app.use(express.json({ limit: '16kb' })); // no reason for large JSON bodies
app.use(express.urlencoded({ extended: false, limit: '16kb' }));
// ── Static files with caching ────────────────────────────
app.use(express.static(path.join(__dirname, 'public'), {
dotfiles: 'deny', // block .env, .git, etc.
maxAge: '1h', // browser caching
}));
// ── Serve uploads from external data directory ──────────
app.use('/uploads', express.static(UPLOADS_DIR, {
dotfiles: 'deny',
maxAge: '1h',
}));
// ── File uploads (images, 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}`);
}
});
const upload = multer({
storage: uploadStorage,
limits: { fileSize: 5 * 1024 * 1024 },
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)'));
}
});
// ── API routes (rate-limited) ────────────────────────────
app.use('/api/auth', authLimiter, authRoutes);
// ── Serve pages ──────────────────────────────────────────
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/app', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'app.html'));
});
app.get('/games/flappy', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'games', 'flappy.html'));
});
// ── Health check (CORS allowed for multi-server status pings) ──
app.get('/api/health', (req, res) => {
res.set('Access-Control-Allow-Origin', '*');
res.json({
status: 'online',
name: process.env.SERVER_NAME || 'Haven',
version: require('./package.json').version
});
});
// ── 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, (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' });
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' });
// 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);
return res.json({ url: `/uploads/${safeName}` });
}
res.json({ url: `/uploads/${req.file.filename}` });
});
});
// ── GIF search proxy (Tenor API — keeps key server-side) ──
function getTenorKey() {
// Check database first (set via admin panel), fall back to .env
try {
const { getDb } = require('./src/database');
const row = getDb().prepare("SELECT value FROM server_settings WHERE key = 'tenor_api_key'").get();
if (row && row.value) return row.value;
} catch { /* DB not ready yet or no key stored */ }
return process.env.TENOR_API_KEY || '';
}
app.get('/api/gif/search', (req, res) => {
const key = getTenorKey();
if (!key) 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);
const url = `https://tenor.googleapis.com/v2/search?q=${encodeURIComponent(q)}&key=${key}&client_key=haven&limit=${limit}&media_filter=tinygif,gif`;
fetch(url).then(r => r.json()).then(data => {
const results = (data.results || []).map(r => ({
id: r.id,
title: r.title || '',
tiny: r.media_formats?.tinygif?.url || '',
full: r.media_formats?.gif?.url || '',
}));
res.json({ results });
}).catch(() => res.status(502).json({ error: 'Tenor API error' }));
});
app.get('/api/gif/trending', (req, res) => {
const key = getTenorKey();
if (!key) return res.status(501).json({ error: 'gif_not_configured' });
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
const url = `https://tenor.googleapis.com/v2/featured?key=${key}&client_key=haven&limit=${limit}&media_filter=tinygif,gif`;
fetch(url).then(r => r.json()).then(data => {
const results = (data.results || []).map(r => ({
id: r.id,
title: r.title || '',
tiny: r.media_formats?.tinygif?.url || '',
full: r.media_formats?.gif?.url || '',
}));
res.json({ results });
}).catch(() => res.status(502).json({ error: 'Tenor 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
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' });
// Only allow http(s) URLs
try {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({ error: 'Only http/https URLs allowed' });
}
// Block private/internal IPs (SSRF protection)
const host = parsed.hostname.toLowerCase();
if (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')) {
return res.status(400).json({ error: 'Private addresses not allowed' });
}
} catch { return res.status(400).json({ error: 'Invalid URL' }); }
// Cache check
const cached = linkPreviewCache.get(url);
if (cached && Date.now() - cached.ts < PREVIEW_CACHE_TTL) {
return res.json(cached.data);
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': 'HavenBot/1.0 (link preview)',
'Accept': 'text/html'
},
redirect: 'follow',
size: PREVIEW_MAX_SIZE
});
clearTimeout(timeout);
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
return res.json({ title: null, description: null, image: null, siteName: null });
}
const html = await resp.text();
// Truncate to max size for safety
const chunk = html.slice(0, PREVIEW_MAX_SIZE);
const getMetaContent = (property) => {
const ogRe = new RegExp(`<meta[^>]+(?:property|name)=["']${property}["'][^>]+content=["']([^"']+)["']`, 'i');
const ogRe2 = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${property}["']`, 'i');
const m = chunk.match(ogRe) || chunk.match(ogRe2);
return m ? m[1].trim() : null;
};
const titleTag = chunk.match(/<title[^>]*>([^<]+)<\/title>/i);
const data = {
title: getMetaContent('og:title') || getMetaContent('twitter:title') || (titleTag ? titleTag[1].trim() : null),
description: getMetaContent('og:description') || getMetaContent('twitter:description') || getMetaContent('description'),
image: getMetaContent('og:image') || getMetaContent('twitter:image'),
siteName: getMetaContent('og:site_name') || new URL(url).hostname,
url: getMetaContent('og:url') || url
};
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 });
}
});
// ── Catch-all: 404 ──────────────────────────────────────
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' });
});
// 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 useSSL = sslCert && sslKey;
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
const httpRedirect = express();
httpRedirect.all('*', (req, res) => {
res.redirect(`https://${req.hostname}:${process.env.PORT || 3000}${req.url}`);
});
const HTTP_REDIRECT_PORT = parseInt(process.env.PORT || 3000) + 1; // 3001
createServer(httpRedirect).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: 20000,
pingInterval: 25000,
connectTimeout: 10000,
// Limit simultaneous connections per IP
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
});
// Initialize
const db = initDatabase();
setupSocketHandlers(io, db);
// ── 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;
// 1. Delete messages older than N days
if (maxAgeDays > 0) {
// Delete reactions for old messages first
db.prepare(`
DELETE FROM reactions WHERE message_id IN (
SELECT id FROM messages WHERE created_at < datetime('now', ?)
)
`).run(`-${maxAgeDays} days`);
const result = db.prepare(
"DELETE FROM messages WHERE created_at < datetime('now', ?)"
).run(`-${maxAgeDays} days`);
totalDeleted += result.changes;
}
// 2. If total DB size exceeds maxSizeMb, trim oldest messages
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 messages to bring size down
const totalCount = db.prepare('SELECT COUNT(*) as cnt FROM messages').get().cnt;
const deleteCount = Math.max(Math.floor(totalCount * 0.1), 100);
const oldestIds = db.prepare(
'SELECT id FROM messages ORDER BY created_at ASC LIMIT ?'
).all(deleteCount).map(r => r.id);
if (oldestIds.length > 0) {
const placeholders = oldestIds.map(() => '?').join(',');
db.prepare(`DELETE FROM reactions WHERE message_id IN (${placeholders})`).run(...oldestIds);
const result = db.prepare(`DELETE FROM messages WHERE id IN (${placeholders})`).run(...oldestIds);
totalDeleted += result.changes;
}
}
}
// Also clean up old uploaded files if age cleanup is set
if (maxAgeDays > 0) {
const uploadsDir = UPLOADS_DIR;
if (require('fs').existsSync(uploadsDir)) {
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
const files = require('fs').readdirSync(uploadsDir);
let filesDeleted = 0;
files.forEach(f => {
try {
const fpath = require('path').join(uploadsDir, f);
const stat = require('fs').statSync(fpath);
if (stat.mtimeMs < cutoff) {
require('fs').unlinkSync(fpath);
filesDeleted++;
}
} catch { /* skip */ }
});
if (filesDeleted > 0) {
console.log(`🗑️ Auto-cleanup: removed ${filesDeleted} old uploaded files`);
}
}
}
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;
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const protocol = useSSL ? 'https' : 'http';
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)}║
╚══════════════════════════════════════════╝
`);
});