Two changes that drop the friction in self-hosting RefBoard so the install
story becomes "docker compose up, open the URL".
1. JWT_SECRET is now optional. On first boot the backend generates a
64-byte random secret and persists it in the existing settings table.
process.env.JWT_SECRET still wins when set, so ops setups that manage
secrets out-of-band are unaffected. The prod-throws-without-env guard
is gone (auto-generation is a strictly safer default than the previous
hardcoded dev fallback).
2. STORAGE_BACKEND=fs|minio picks between MinIO (default, unchanged) and
a new local-filesystem adapter. The FS adapter exposes a fake minioClient
that mirrors the methods RefBoard calls (statObject, getObject,
getPartialObject, listObjectsV2, putObject, removeObject(s), bucketExists,
makeBucket), so consumers swap require('./minio') for require('./storage')
and nothing else changes. Sidecar .mime files hold Content-Type so the
range-aware media proxy still serves the right response headers.
examples/compose/minimal-fs.yml is the single-container variant that uses
the FS adapter. The default docker-compose.yml still spins up MinIO.
README quick-start collapses to one block (cp .env, docker compose pull,
docker compose up -d). The first user you register on the Login screen is
auto-promoted to admin, same as before.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
237 lines
7.7 KiB
JavaScript
237 lines
7.7 KiB
JavaScript
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('./storage');
|
|
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 collectionRoutes = require('./routes/collections');
|
|
const boardRoutes = require('./routes/boards');
|
|
const uploadRoutes = require('./routes/upload');
|
|
const adminRoutes = require('./routes/admin');
|
|
const threadRoutes = require('./routes/threads');
|
|
const pdfRoutes = require('./routes/pdf');
|
|
const activityRoutes = require('./routes/activity');
|
|
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/collections', collectionRoutes);
|
|
app.use('/api/boards', boardRoutes);
|
|
app.use('/api/upload', uploadRoutes);
|
|
app.use('/api/admin', adminRoutes);
|
|
app.use('/api/boards', threadRoutes);
|
|
app.use('/api/boards', pdfRoutes);
|
|
app.use('/api/boards', activityRoutes);
|
|
|
|
// 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() {
|
|
const dbModule = require('./db');
|
|
console.log('[server] Database initialized');
|
|
|
|
// Resolve/generate the JWT secret eagerly so the "generated a fresh one"
|
|
// log line surfaces at boot, not on the first auth request.
|
|
dbModule.getOrCreateJwtSecret();
|
|
|
|
try {
|
|
await dbModule.seedAdminFromEnv();
|
|
} catch (err) {
|
|
console.error('[server] SEED_ADMIN bootstrap failed:', err.message);
|
|
}
|
|
|
|
try {
|
|
const { initBucket } = require('./storage');
|
|
await initBucket();
|
|
console.log('[server] Storage backend initialized');
|
|
} catch (err) {
|
|
console.error('[server] Storage initialization failed:', err.message);
|
|
console.error('[server] Image uploads will not work until storage is available');
|
|
}
|
|
|
|
// 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 { 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);
|
|
});
|