customer-presentation/server.js
2026-04-22 17:31:45 +02:00

150 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 { 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 / → always the entrance shell. If authed, entrance.js routes
// the user to the welcome step client-side (preserving the
// email/code UI as the no-session fallback).
// GET /timeline → gated timeline page (protected/index.html).
app.get('/', (req, res) => {
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
});
app.get('/timeline', requireAuth, (req, res) => {
return res.sendFile(path.join(__dirname, 'protected', 'index.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);
});
}