// ── 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'", "https://www.youtube.com", "https://w.soundcloud.com"], styleSrc: ["'self'", "'unsafe-inline'"], // inline styles needed for themes imgSrc: ["'self'", "data:", "blob:", "https:"], // https: for link preview OG images + GIPHY connectSrc: ["'self'", "ws:", "wss:"], // Socket.IO (ws for HTTP, wss for HTTPS) mediaSrc: ["'self'", "blob:", "data:"], // WebRTC audio + notification sounds fontSrc: ["'self'"], objectSrc: ["'none'"], frameSrc: ["https://open.spotify.com", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://w.soundcloud.com"], // Listen Together embeds baseUri: ["'self'"], formAction: ["'self'"], frameAncestors: ["'none'"], // prevent clickjacking } }, crossOriginEmbedderPolicy: false, // needed for WebRTC crossOriginOpenerPolicy: false, // needed for WebRTC hsts: { maxAge: 31536000, includeSubDomains: false }, // force HTTPS for 1 year referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, })); // Additional security headers helmet doesn't cover app.use((req, res, next) => { res.setHeader('Permissions-Policy', 'camera=(), geolocation=(), payment=()'); res.setHeader('X-Content-Type-Options', 'nosniff'); next(); }); // 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. etag: true, // ETag for conditional requests lastModified: true, // Last-Modified header maxAge: 0, // always revalidate — prevents stale JS/CSS after deploys })); // ── Serve uploads from external data directory ────────── app.use('/uploads', express.static(UPLOADS_DIR, { dotfiles: 'deny', maxAge: '1h', 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)) { res.setHeader('Content-Disposition', 'attachment'); } } })); // ── File uploads (images max 5 MB, general files max 25 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 (existing endpoint) 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)')); } }); // General file upload (expanded MIME whitelist) const ALLOWED_FILE_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'text/markdown', 'application/zip', 'application/x-zip-compressed', 'application/x-7z-compressed', 'application/x-rar-compressed', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'video/mp4', 'video/webm', 'application/json', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ]); const fileUpload = multer({ storage: uploadStorage, limits: { fileSize: 25 * 1024 * 1024 }, fileFilter: (req, file, cb) => { if (ALLOWED_FILE_TYPES.has(file.mimetype)) cb(null, true); else cb(new Error('File type not allowed')); } }); // ── 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 intentionally omitted — don't fingerprint the server for attackers }); }); // ── 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' }); // 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); return res.json({ url: `/uploads/${safeName}` }); } res.json({ url: `/uploads/${req.file.filename}` }); }); }); // ── General file upload (authenticated + not banned) ───── app.post('/api/upload-file', 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' }); 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' }); 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' }); const isImage = /^image\//.test(req.file.mimetype); const originalName = req.file.originalname || 'file'; const fileSize = req.file.size; res.json({ url: `/uploads/${req.file.filename}`, originalName, fileSize, isImage, mimetype: req.file.mimetype }); }); }); // ── Avatar upload (authenticated, image only, max 2 MB) ── app.post('/api/upload-avatar', 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' }); 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: 'Avatar 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 avatarUrl = `/uploads/${req.file.filename}`; const { getDb } = require('./src/database'); getDb().prepare('UPDATE users SET avatar = ? WHERE id = ?').run(avatarUrl, user.id); res.json({ url: avatarUrl }); }); }); // ── Sound upload (admin only, wav/mp3/ogg, max 1 MB) ──── const soundUpload = multer({ storage: uploadStorage, limits: { fileSize: 1 * 1024 * 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, (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 (!user.isAdmin) return res.status(403).json({ error: 'Admin only' }); soundUpload.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().toLowerCase().replace(/[^a-z0-9_-]/g, ''); 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); 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 sounds = getDb().prepare('SELECT name, filename FROM custom_sounds ORDER BY name').all(); res.json({ sounds: sounds.map(s => ({ name: s.name, url: `/uploads/${s.filename}` })) }); } catch { res.json({ 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 (!user.isAdmin) return res.status(403).json({ error: 'Admin only' }); const name = req.params.name; const { getDb } = require('./src/database'); try { 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); } res.json({ ok: true }); } catch { res.status(500).json({ error: 'Failed to delete sound' }); } }); // ── GIF search proxy (GIPHY API — keeps key server-side) ── function getGiphyKey() { // 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 = 'giphy_api_key'").get(); if (row && row.value) return row.value; } catch { /* DB not ready yet or no key stored */ } return process.env.GIPHY_API_KEY || ''; } // ── 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 key = getGiphyKey(); 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://api.giphy.com/v1/gifs/search?api_key=${encodeURIComponent(key)}&q=${encodeURIComponent(q)}&limit=${limit}&rating=r&lang=en`; fetch(url).then(r => r.json()).then(data => { const results = (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 || '', })); res.json({ results }); }).catch(() => res.status(502).json({ error: 'GIPHY 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 key = getGiphyKey(); if (!key) return res.status(501).json({ error: 'gif_not_configured' }); const limit = Math.min(parseInt(req.query.limit) || 20, 50); const url = `https://api.giphy.com/v1/gifs/trending?api_key=${encodeURIComponent(key)}&limit=${limit}&rating=r`; fetch(url).then(r => r.json()).then(data => { const results = (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 || '', })); res.json({ results }); }).catch(() => res.status(502).json({ error: 'GIPHY 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 const dns = require('dns'); const { promisify } = require('util'); const dnsResolve = promisify(dns.resolve4); // Rate limit link preview fetches (per IP, separate from upload limiter) const previewLimitStore = new Map(); function previewLimiter(req, res, next) { const ip = req.ip || req.socket.remoteAddress; const now = Date.now(); const windowMs = 60 * 1000; const maxReqs = 30; // 30 previews per minute per user 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 res.status(429).json({ error: 'Rate limited — try again shortly' }); stamps.push(now); next(); } 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:'); } app.get('/api/link-preview', previewLimiter, 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 let parsed; try { parsed = new URL(url); if (!['http:', 'https:'].includes(parsed.protocol)) { return res.status(400).json({ error: 'Only http/https URLs allowed' }); } // Block private/internal hostnames (SSRF protection — layer 1: hostname check) 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' }); } // SSRF protection — layer 2: DNS resolution check (defeats DNS rebinding) try { const addresses = await dnsResolve(parsed.hostname); if (addresses.some(isPrivateIP)) { return res.status(400).json({ error: 'Private addresses not allowed' }); } } catch { // DNS resolution failed — could be IPv6-only or non-existent; allow fetch to fail naturally } // 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(`]+(?:property|name)=["']${property}["'][^>]+content=["']([^"']+)["']`, 'i'); const ogRe2 = new RegExp(`]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${property}["']`, 'i'); const m = chunk.match(ogRe) || chunk.match(ogRe2); return m ? m[1].trim() : null; }; const titleTag = chunk.match(/