Adds GET /api/fenjaops/events and three new panels: per-type totals + device breakdown, per-user summary (logins / timeline views / last seen), and a raw event log capped at the newest 500 rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
274 lines
11 KiB
JavaScript
274 lines
11 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, requireAdmin } from './src/middleware.js';
|
|
import { q } from './src/db.js'; // also side-effect: opens DB + runs schema
|
|
import { MOBILE_UA_RE } from './src/ua.js';
|
|
import { recordEvent } from './src/events.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const app = express();
|
|
|
|
// ─── 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);
|
|
|
|
// ─── Bifrost join tracking (gated) ───────────────────────────
|
|
// Records every click of the final CTA button as its own row so the
|
|
// full history per user is preserved (who, when, which session).
|
|
app.post('/api/bifrost-join', requireAuth, (req, res) => {
|
|
const { email, id: sessionId } = req.session;
|
|
const clickedAt = Date.now();
|
|
q.recordJoin.run(email, clickedAt, sessionId);
|
|
res.json({ clicked_at: clickedAt });
|
|
});
|
|
|
|
// ─── Admin surface (gated, unlisted) ─────────────────────────
|
|
// Accessible only to invite rows with `is_admin=1`. Non-admins —
|
|
// authed or not — get a plain 404 from requireAdmin so the route's
|
|
// existence is not leaked. Admin files live in ./admin/ (outside
|
|
// both public/ and protected/) so they can only be reached through
|
|
// these explicit routes.
|
|
//
|
|
// The public URL path is deliberately obscure — `/fenjaops` rather
|
|
// than `/admin` — so scripted probes of common admin paths miss.
|
|
// Internal names (files, middleware, CLI subcommand) stay as "admin"
|
|
// since the obscurity is a URL concern only, not an identity one.
|
|
// Grant admin via:
|
|
// node bin/invite.js admin add <email>
|
|
app.use('/fenjaops',
|
|
requireAuth,
|
|
requireAdmin,
|
|
express.static(path.join(__dirname, 'admin'), { index: 'index.html', maxAge: 0 })
|
|
);
|
|
|
|
app.get('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
|
|
res.json(q.listInvites.all());
|
|
});
|
|
|
|
// Create a non-admin invite from the admin UI. Admin promotion is
|
|
// intentionally NOT exposed — granting admin stays on the CLI
|
|
// (`bin/invite.js admin add`) so that compromising a web session
|
|
// cannot escalate the invite list. Audit trail: invited_by is the
|
|
// admin's email (not "cli").
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
app.post('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
|
|
const { email, first_name } = req.body ?? {};
|
|
|
|
if (typeof email !== 'string' || !EMAIL_RE.test(email.trim())) {
|
|
return res.status(400).json({ error: 'invalid_email' });
|
|
}
|
|
const normalized = email.trim().toLowerCase();
|
|
|
|
let firstName = null;
|
|
if (first_name != null && first_name !== '') {
|
|
if (typeof first_name !== 'string') {
|
|
return res.status(400).json({ error: 'invalid_first_name' });
|
|
}
|
|
const trimmed = first_name.trim();
|
|
if (trimmed.length > 64) {
|
|
return res.status(400).json({ error: 'first_name_too_long' });
|
|
}
|
|
firstName = trimmed || null;
|
|
}
|
|
|
|
if (q.getInvite.get(normalized)) {
|
|
return res.status(409).json({ error: 'already_invited' });
|
|
}
|
|
|
|
q.upsertInvite.run(normalized, firstName, Date.now(), req.session.email);
|
|
return res.status(201).json({ email: normalized, first_name: firstName });
|
|
});
|
|
|
|
// Remove a non-admin invite from the admin UI. Guardrails:
|
|
// (1) cannot remove an admin row — demote via the CLI first,
|
|
// so a compromised admin session can't lock everyone out;
|
|
// (2) cannot remove yourself — prevents self-inflicted lockouts.
|
|
// Deleting the invite also deletes any active sessions for that
|
|
// email so the user is kicked out immediately instead of holding
|
|
// their 30-day cookie.
|
|
app.delete('/api/fenjaops/invites/:email', requireAuth, requireAdmin, (req, res) => {
|
|
const raw = decodeURIComponent(req.params.email || '').trim().toLowerCase();
|
|
if (!raw || !EMAIL_RE.test(raw)) {
|
|
return res.status(400).json({ error: 'invalid_email' });
|
|
}
|
|
if (raw === req.session.email) {
|
|
return res.status(400).json({ error: 'cannot_remove_self' });
|
|
}
|
|
const invite = q.getInvite.get(raw);
|
|
if (!invite) {
|
|
return res.status(404).json({ error: 'not_found' });
|
|
}
|
|
if (invite.is_admin) {
|
|
return res.status(403).json({ error: 'cannot_remove_admin' });
|
|
}
|
|
q.deleteInvite.run(raw);
|
|
q.deleteSessionsForEmail.run(raw);
|
|
return res.status(200).json({ email: raw, deleted: true });
|
|
});
|
|
|
|
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
|
|
res.json({
|
|
clicks: q.listJoins.all(),
|
|
summary: q.summariseJoins.all(),
|
|
total_clicks: q.countJoins.get().n,
|
|
unique_users: q.countUniqueJoiners.get().n,
|
|
});
|
|
});
|
|
|
|
// Engagement events read-out — login + timeline_view rows with parsed
|
|
// device fields. Capped at the 500 most recent events so the JSON
|
|
// payload stays small as the table grows. CLI (`bin/events.js`) is
|
|
// the path for unbounded queries.
|
|
app.get('/api/fenjaops/events', requireAuth, requireAdmin, (req, res) => {
|
|
res.json({
|
|
events: q.listEvents.all(500),
|
|
summary: q.summariseEvents.all(),
|
|
by_type: q.countEventsByType.all(),
|
|
device_breakdown: q.deviceBreakdown.all(),
|
|
});
|
|
});
|
|
|
|
// ─── Root dispatch ───────────────────────────────────────────
|
|
// GET / → always the entrance shell. If authed, entrance.js routes
|
|
// the user to the welcome step client-side.
|
|
// GET /timeline → gated timeline page (protected/index.html).
|
|
app.get('/', (req, res) => {
|
|
return res.sendFile(path.join(__dirname, 'public', 'entrance.html'));
|
|
});
|
|
|
|
// UA sniff for the mobile minimum-viable view. Covers the common
|
|
// phone cases; tablets on iPadOS falsely identify as desktop and will
|
|
// see the full animated site, which is the right default for a 10"+
|
|
// screen. The `?view=mobile` / `?view=desktop` query override exists
|
|
// for cases where the guess is wrong or someone wants to preview the
|
|
// other version — it takes precedence over the UA regex.
|
|
function wantsMobileView(req) {
|
|
const forced = (req.query.view || '').toLowerCase();
|
|
if (forced === 'mobile') return true;
|
|
if (forced === 'desktop') return false;
|
|
return MOBILE_UA_RE.test(req.headers['user-agent'] || '');
|
|
}
|
|
|
|
app.get('/timeline', requireAuth, (req, res) => {
|
|
const forced = ['mobile', 'desktop'].includes((req.query.view || '').toLowerCase());
|
|
const view = wantsMobileView(req) ? 'mobile' : 'desktop';
|
|
recordEvent(req, {
|
|
type: 'timeline_view',
|
|
email: req.session.email,
|
|
sessionId: req.session.id,
|
|
meta: { view, forced },
|
|
});
|
|
if (view === 'mobile') {
|
|
return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html'));
|
|
}
|
|
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}`;
|
|
|
|
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);
|
|
});
|
|
}
|