151 lines
6.3 KiB
JavaScript
151 lines
6.3 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
|
// server.js — entry point.
|
|
//
|
|
// Binds to 127.0.0.1 only. Nginx reverse-proxies to this port.
|
|
// Never expose Node directly to the public internet.
|
|
// ─────────────────────────────────────────────────────────────
|
|
import 'dotenv/config';
|
|
import express from 'express';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import authRouter from './src/auth.js';
|
|
import { requireAuth } from './src/middleware.js';
|
|
import { currentSession } from './src/sessions.js';
|
|
import { initMail } from './src/mail.js';
|
|
import './src/db.js'; // side-effect import: opens DB + runs schema
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const app = express();
|
|
|
|
// ─── Sanity checks at boot ───────────────────────────────────
|
|
if (!process.env.CODE_PEPPER || process.env.CODE_PEPPER.length < 32) {
|
|
console.error('FATAL: CODE_PEPPER is missing or too short. Generate with:\n openssl rand -hex 32');
|
|
process.exit(1);
|
|
}
|
|
|
|
// ─── Trust Nginx as the single upstream proxy ────────────────
|
|
// This makes req.ip reflect X-Forwarded-For (the real client IP)
|
|
// instead of 127.0.0.1. Only "loopback" is trusted, so spoofed
|
|
// XFF headers from the public internet are ignored.
|
|
app.set('trust proxy', 'loopback');
|
|
|
|
// ─── Body parsers ────────────────────────────────────────────
|
|
app.use(express.json({ limit: '4kb' }));
|
|
app.use(cookieParser());
|
|
|
|
// ─── Security headers on every response ──────────────────────
|
|
app.use((req, res, next) => {
|
|
res.set({
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Frame-Options': 'DENY',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
// Tight CSP — only our own origin, and inline <style> blocks are allowed
|
|
// because the design system ships styles inline inside the artifacts.
|
|
'Content-Security-Policy': [
|
|
"default-src 'self'",
|
|
"script-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data:",
|
|
"font-src 'self'",
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join('; '),
|
|
});
|
|
next();
|
|
});
|
|
|
|
// ─── Tiny request log ────────────────────────────────────────
|
|
app.use((req, res, next) => {
|
|
const started = Date.now();
|
|
res.on('finish', () => {
|
|
const ms = Date.now() - started;
|
|
const line = `${new Date().toISOString()} ${req.ip} ${req.method} ${req.path} → ${res.statusCode} (${ms}ms)`;
|
|
console.log(line);
|
|
});
|
|
next();
|
|
});
|
|
|
|
// ─── Auth endpoints (public) ─────────────────────────────────
|
|
app.use('/auth', authRouter);
|
|
|
|
// ─── Root dispatch ───────────────────────────────────────────
|
|
// GET / → timeline (if authed) | entrance (otherwise)
|
|
// GET /entrance → always the entrance (useful for "log in as someone else")
|
|
// Other paths fall through to the static handlers below.
|
|
app.get('/', (req, res, next) => {
|
|
if (currentSession(req)) {
|
|
// Authed: serve the timeline directly from /protected/index.html
|
|
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
|
|
}
|
|
// Not authed: serve the entrance
|
|
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
|
|
});
|
|
|
|
// ─── Public static assets (entrance.js, etc.) ────────────────
|
|
// Fallthrough so Express can still try the routes below if nothing matches.
|
|
app.use(
|
|
express.static(path.join(__dirname, 'public'), {
|
|
index: false, // don't auto-serve entrance.html at /
|
|
extensions: ['html'],
|
|
fallthrough: true,
|
|
maxAge: 0,
|
|
})
|
|
);
|
|
|
|
// ─── GATED — everything in protected/ needs a session cookie ─
|
|
// Handles the authed home page assets:
|
|
// /timeline.js, /vendor/*, /fenja/colors_and_type.css, /archive.html, etc.
|
|
app.use(
|
|
requireAuth,
|
|
express.static(path.join(__dirname, 'protected'), {
|
|
index: false, // we dispatch / ourselves above
|
|
extensions: ['html'],
|
|
maxAge: 0,
|
|
})
|
|
);
|
|
|
|
// ─── 404 fallback ────────────────────────────────────────────
|
|
app.use((req, res) => {
|
|
if (req.accepts('html')) {
|
|
return res.status(404).send('<h1>404 — not found</h1>');
|
|
}
|
|
res.status(404).end();
|
|
});
|
|
|
|
// ─── Error handler ───────────────────────────────────────────
|
|
app.use((err, req, res, _next) => {
|
|
console.error('[error]', err);
|
|
res.status(500).json({ error: 'internal' });
|
|
});
|
|
|
|
// ─── Start ───────────────────────────────────────────────────
|
|
const port = Number(process.env.PORT || 3000);
|
|
const origin = process.env.PUBLIC_ORIGIN || `http://127.0.0.1:${port}`;
|
|
|
|
(async () => {
|
|
try {
|
|
await initMail();
|
|
console.log('[mail] SMTP relay reachable');
|
|
} catch (err) {
|
|
console.error('[mail] SMTP verify failed:', err?.message || err);
|
|
console.error(' Fix .env and restart. The app will still boot so you can debug, but /auth/request-code will silently drop mail.');
|
|
}
|
|
|
|
app.listen(port, '127.0.0.1', () => {
|
|
console.log(`[bifrost] listening on 127.0.0.1:${port}`);
|
|
console.log(`[bifrost] public origin: ${origin}`);
|
|
});
|
|
})();
|
|
|
|
// ─── Graceful shutdown ───────────────────────────────────────
|
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
process.on(sig, () => {
|
|
console.log(`[bifrost] ${sig} received, exiting.`);
|
|
process.exit(0);
|
|
});
|
|
}
|