const http = require('http'); const express = require('express'); const cors = require('cors'); const path = require('path'); // ---- Load environment ---- const PORT = parseInt(process.env.PORT || '8000', 10); // ---- Express app ---- const app = express(); app.use(cors({ origin: process.env.CORS_ORIGIN || '*' })); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); // ---- Health check ---- app.get('/health', (_req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // ---- Media proxy (MinIO → browser) with HTTP Range support ---- app.get('/api/images/*', async (req, res) => { try { const objectPath = req.params[0]; // everything after /api/images/ if (!objectPath) return res.status(400).json({ error: 'Missing path' }); const { minioClient, MINIO_BUCKET } = require('./minio'); const stat = await minioClient.statObject(MINIO_BUCKET, objectPath); const contentType = stat.metaData?.['content-type'] || 'application/octet-stream'; const totalSize = stat.size; res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); res.setHeader('Accept-Ranges', 'bytes'); const rangeHeader = req.headers.range; if (rangeHeader && totalSize) { // Parse Range: bytes=start-end const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (match) { const start = parseInt(match[1], 10); const requestedEnd = match[2] ? parseInt(match[2], 10) : totalSize - 1; const end = Math.min(requestedEnd, totalSize - 1); if (start >= totalSize || end < start) { res.status(416).setHeader('Content-Range', `bytes */${totalSize}`).end(); return; } const chunkSize = end - start + 1; res.status(206); res.setHeader('Content-Type', contentType); res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`); res.setHeader('Content-Length', chunkSize); const stream = await minioClient.getPartialObject(MINIO_BUCKET, objectPath, start, chunkSize); stream.pipe(res); return; } } // Full response res.setHeader('Content-Type', contentType); if (totalSize) res.setHeader('Content-Length', totalSize); const stream = await minioClient.getObject(MINIO_BUCKET, objectPath); stream.pipe(res); } catch (err) { if (err.code === 'NoSuchKey' || err.code === 'NotFound') { return res.status(404).json({ error: 'Image not found' }); } console.error('[server] media proxy error:', err); return res.status(500).json({ error: 'Failed to serve media' }); } }); // ---- User search (authenticated) ---- app.get('/api/users/search', (req, res) => { try { const { authMiddleware } = require('./auth'); authMiddleware(req, res, () => { const { q } = req.query; if (!q || q.length < 1) return res.json({ users: [] }); const { getAllUsers } = require('./db'); const all = getAllUsers(); const query = q.toLowerCase(); const matched = all .filter(u => u.is_active && (u.email.toLowerCase().includes(query) || u.username.toLowerCase().includes(query) || (u.display_name || '').toLowerCase().includes(query))) .slice(0, 10) .map(u => ({ id: u.id, email: u.email, username: u.username, display_name: u.display_name })); return res.json({ users: matched }); }); } catch (err) { console.error('[server] user search error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // ---- API routes ---- const authRoutes = require('./routes/auth'); const oauthRoutes = require('./routes/oauth'); const collectionRoutes = require('./routes/collections'); const boardRoutes = require('./routes/boards'); const uploadRoutes = require('./routes/upload'); const adminRoutes = require('./routes/admin'); const mmBridgeRoutes = require('./routes/mattermost-bridge'); const threadRoutes = require('./routes/threads'); const pdfRoutes = require('./routes/pdf'); app.use('/api/auth', authRoutes); app.use('/api/auth', oauthRoutes); app.use('/api/collections', collectionRoutes); app.use('/api/boards', boardRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/admin', adminRoutes); app.use('/api/boards', mmBridgeRoutes); app.use('/api/boards', threadRoutes); app.use('/api/boards', pdfRoutes); // Public shared collection route (no auth required) app.get('/api/c/:shareToken', (req, res) => { try { const { getCollectionByShareToken, getCollectionBoards } = require('./db'); const collection = getCollectionByShareToken(req.params.shareToken); if (!collection || !collection.is_public) { return res.status(404).json({ error: 'Collection not found' }); } const boards = getCollectionBoards(collection.id); return res.json({ collection, boards }); } catch (err) { console.error('[server] shared collection error:', err); return res.status(500).json({ error: 'Internal server error' }); } }); // ---- Static frontend files ---- const frontendDist = path.join(__dirname, '..', 'frontend', 'dist'); app.use(express.static(frontendDist)); // SPA fallback — send index.html for non-API routes app.get('*', (req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'API endpoint not found' }); } res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) { res.status(404).json({ error: 'Frontend not built yet' }); } }); }); // ---- Global error handler ---- app.use((err, _req, res, _next) => { console.error('[server] Unhandled error:', err); res.status(500).json({ error: 'Internal server error' }); }); // ---- Create HTTP server + Socket.IO ---- const server = http.createServer(app); const { setupSocket } = require('./socket'); const io = setupSocket(server); app.set('io', io); // ---- Initialize services and start ---- async function start() { require('./db'); console.log('[server] Database initialized'); try { const { initBucket } = require('./minio'); await initBucket(); console.log('[server] MinIO initialized'); } catch (err) { console.error('[server] MinIO initialization failed:', err.message); console.error('[server] Image uploads will not work until MinIO is available'); } // Start Mattermost auto-sync watcher (no-op if env vars missing) try { const { startWatcher } = require('./services/mm-watcher'); startWatcher(io); } catch (err) { console.error('[server] MM watcher failed to start:', err.message); } // Start media processing worker try { const { startMediaWorker } = require('./services/media-worker'); startMediaWorker(io); } catch (err) { console.error('[server] Media worker failed to start:', err.message); } server.listen(PORT, '0.0.0.0', () => { console.log(`[server] RefBoard backend listening on port ${PORT}`); }); } // ---- Graceful shutdown ---- function shutdown(signal) { console.log(`[server] Received ${signal}, shutting down gracefully...`); try { const { stopWatcher } = require('./services/mm-watcher'); stopWatcher(); } catch {} try { const { stopMediaWorker } = require('./services/media-worker'); stopMediaWorker(); } catch {} io.close(() => { console.log('[server] Socket.IO closed'); }); server.close(() => { console.log('[server] HTTP server closed'); try { const { db } = require('./db'); db.close(); console.log('[server] Database closed'); } catch (e) { // ignore } process.exit(0); }); setTimeout(() => { console.error('[server] Forced shutdown after timeout'); process.exit(1); }, 10000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); start().catch((err) => { console.error('[server] Failed to start:', err); process.exit(1); });