Valgfri app-auth ved siden av proxy-modus (default uendret). Signert sesjons-cookie (scrypt-passord, HMAC, ingen avhengigheter), gating av API + WebSocket, og en selvstendig neon-login (public/login.html) for uautentiserte. Trusted bypass: en proxy-identitet (X-Remote-User / Basic Auth) regnes som innlogget uten app-passord (TRUST_PROXY_HEADER, default på), så man kan bootstrappe: logg inn via NPM Basic Auth → sett app-passord i konto-modalen → skru av Basic Auth. Konto-UI for sett/endre passord + logg ut. AUTH_MODE leses per createServer-instans (testbart). 34 integrasjonstester (8 nye for app-auth), alle grønne. README/CLAUDE.md/setup-skript oppdatert. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
434 lines
18 KiB
JavaScript
434 lines
18 KiB
JavaScript
'use strict';
|
|
/**
|
|
* NetOps To-Do — server.
|
|
*
|
|
* Express for REST-API + statiske filer, ws for sanntidspush.
|
|
* Alle mutasjoner går via REST (lett å curl-e og feilsøke); WebSocket brukes
|
|
* kun til å pushe endringer, presence og "redigerer nå"-signaler til klientene.
|
|
*
|
|
* To auth-modi (AUTH_MODE):
|
|
* proxy (default) — stol på reverse proxy: X-Remote-User > Basic Auth >
|
|
* netops_user-cookie (selvvalgt navn) > 'anon'. Ingen passord.
|
|
* app — innebygd innlogging: signert sesjons-cookie (lib/auth.js).
|
|
* En "trusted bypass" lar en proxy-identitet (X-Remote-User
|
|
* eller Basic Auth) slippe inn uten app-passord, slik at man
|
|
* kan bootstrappe: logg inn via proxy → sett app-passord →
|
|
* skru av proxy-auth. Styres av TRUST_PROXY_HEADER (default på).
|
|
*/
|
|
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const express = require('express');
|
|
const { WebSocketServer } = require('ws');
|
|
const { Store, NotFoundError, ConflictError } = require('./lib/store');
|
|
const { Auth } = require('./lib/auth');
|
|
|
|
const COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1';
|
|
const SESSION_COOKIE = 'netops_session';
|
|
|
|
function sanitizeUser(raw) {
|
|
if (typeof raw !== 'string') return '';
|
|
return raw.replace(/[^A-Za-z0-9._@\-]/g, '').slice(0, 64);
|
|
}
|
|
|
|
function parseCookies(header) {
|
|
const out = {};
|
|
if (!header) return out;
|
|
for (const part of header.split(';')) {
|
|
const i = part.indexOf('=');
|
|
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Felles for HTTP-requests og WebSocket-upgrade (begge har headers).
|
|
function userFromRequest(req) {
|
|
let u = sanitizeUser(req.headers['x-remote-user']);
|
|
if (u) return u;
|
|
const auth = req.headers.authorization || '';
|
|
if (auth.startsWith('Basic ')) {
|
|
try {
|
|
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
|
u = sanitizeUser(decoded.split(':')[0]);
|
|
if (u) return u;
|
|
} catch { /* ignorer ugyldig base64 */ }
|
|
}
|
|
if (COOKIE_AUTH_ENABLED) {
|
|
u = sanitizeUser(parseCookies(req.headers.cookie).netops_user);
|
|
if (u) return u;
|
|
}
|
|
return 'anon';
|
|
}
|
|
|
|
// Identitet fra proxy alene (header eller Basic Auth) — IKKE selvvalgt navn-cookie.
|
|
// Brukes til "trusted bypass" i app-modus.
|
|
function proxyUser(req) {
|
|
let u = sanitizeUser(req.headers['x-remote-user']);
|
|
if (u) return u;
|
|
const a = req.headers.authorization || '';
|
|
if (a.startsWith('Basic ')) {
|
|
try {
|
|
u = sanitizeUser(Buffer.from(a.slice(6), 'base64').toString('utf8').split(':')[0]);
|
|
if (u) return u;
|
|
} catch { /* ignorer ugyldig base64 */ }
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function createServer({
|
|
dataDir = path.join(__dirname, 'data'),
|
|
authMode = process.env.AUTH_MODE === 'app' ? 'app' : 'proxy',
|
|
trustProxyHeader = process.env.TRUST_PROXY_HEADER !== '0',
|
|
sessionTtlDays = Number(process.env.SESSION_TTL_DAYS) || 30,
|
|
} = {}) {
|
|
const store = new Store(dataDir);
|
|
store.load();
|
|
const auth = new Auth(dataDir, { ttlDays: sessionTtlDays });
|
|
auth.load();
|
|
|
|
// Løs opp identitet + om requesten er autentisert (gjelder begge auth-modi).
|
|
function resolveAuth(req) {
|
|
if (authMode !== 'app') return { user: userFromRequest(req), authed: true, via: 'proxy' };
|
|
const sUser = auth.verifyToken(parseCookies(req.headers.cookie)[SESSION_COOKIE]);
|
|
if (sUser) return { user: sUser, authed: true, via: 'session' };
|
|
if (trustProxyHeader) {
|
|
const pu = proxyUser(req);
|
|
if (pu) return { user: pu, authed: true, via: 'proxy' };
|
|
}
|
|
return { user: null, authed: false, via: null };
|
|
}
|
|
|
|
function sessionCookie(req, token, maxAgeSec) {
|
|
const secure = (req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted) ? '; Secure' : '';
|
|
const age = maxAgeSec != null ? `; Max-Age=${maxAgeSec}` : '';
|
|
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secure}${age}`;
|
|
}
|
|
|
|
const app = express();
|
|
app.disable('x-powered-by');
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use((req, res, next) => {
|
|
const a = resolveAuth(req);
|
|
req.user = a.user;
|
|
req.authed = a.authed;
|
|
req.authVia = a.via;
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store');
|
|
next();
|
|
});
|
|
|
|
const server = http.createServer(app);
|
|
|
|
// ---------- WebSocket ----------
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
// ws → { user, editing: taskId|null, isAlive }
|
|
const clients = new Map();
|
|
|
|
server.on('upgrade', (req, socket, head) => {
|
|
if (!req.url.startsWith('/ws')) { socket.destroy(); return; }
|
|
if (!resolveAuth(req).authed) { socket.destroy(); return; } // krev innlogging i app-modus
|
|
wss.handleUpgrade(req, socket, head, ws => {
|
|
wss.emit('connection', ws, req);
|
|
});
|
|
});
|
|
|
|
function broadcast(obj) {
|
|
const msg = JSON.stringify(obj);
|
|
for (const ws of clients.keys()) {
|
|
if (ws.readyState === ws.OPEN) ws.send(msg);
|
|
}
|
|
}
|
|
|
|
function onlineUsers() {
|
|
const set = new Set();
|
|
for (const meta of clients.values()) set.add(meta.user);
|
|
return [...set].sort();
|
|
}
|
|
|
|
// { taskId: [brukernavn, ...] } — hvem som har redigeringsmodal åpen
|
|
function editingState() {
|
|
const out = {};
|
|
for (const meta of clients.values()) {
|
|
if (!meta.editing) continue;
|
|
(out[meta.editing] ||= []).push(meta.user);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function broadcastPresence() {
|
|
broadcast({ type: 'presence', online: onlineUsers() });
|
|
}
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const user = resolveAuth(req).user || 'anon';
|
|
clients.set(ws, { user, editing: null, isAlive: true });
|
|
store.rememberUser(user);
|
|
|
|
ws.send(JSON.stringify({ type: 'hello', user, online: onlineUsers(), editing: editingState() }));
|
|
ws.send(JSON.stringify({ type: 'sync', tasks: store.list(), activity: store.activity }));
|
|
broadcastPresence();
|
|
|
|
ws.on('pong', () => { const m = clients.get(ws); if (m) m.isAlive = true; });
|
|
|
|
ws.on('message', raw => {
|
|
let msg;
|
|
try { msg = JSON.parse(raw); } catch { return; }
|
|
const meta = clients.get(ws);
|
|
if (!meta) return;
|
|
if (msg.type === 'editing') {
|
|
meta.editing = msg.active && typeof msg.id === 'string' ? msg.id.slice(0, 32) : null;
|
|
broadcast({ type: 'editing', editing: editingState() });
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
const meta = clients.get(ws);
|
|
clients.delete(ws);
|
|
if (meta && meta.editing) broadcast({ type: 'editing', editing: editingState() });
|
|
broadcastPresence();
|
|
});
|
|
});
|
|
|
|
// Heartbeat: dropp døde tilkoblinger (klient bak NAT/proxy som forsvant).
|
|
const heartbeat = setInterval(() => {
|
|
for (const [ws, meta] of clients) {
|
|
if (!meta.isAlive) { ws.terminate(); clients.delete(ws); continue; }
|
|
meta.isAlive = false;
|
|
ws.ping();
|
|
}
|
|
}, 30000);
|
|
heartbeat.unref();
|
|
|
|
// ---------- REST API ----------
|
|
const todayStr = () => new Date().toISOString().slice(0, 10);
|
|
const validDate = d => typeof d === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d);
|
|
const LOGIN_PAGE = path.join(__dirname, 'public', 'login.html');
|
|
|
|
// ----- Offentlige ruter (krever ikke innlogging) -----
|
|
app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length }));
|
|
app.get('/login', (req, res) => res.sendFile(LOGIN_PAGE));
|
|
|
|
app.get('/api/auth/status', (req, res) => {
|
|
res.json({
|
|
mode: authMode,
|
|
authed: req.authed,
|
|
user: req.user,
|
|
via: req.authVia,
|
|
hasPassword: req.authed ? auth.hasPassword(req.user) : false,
|
|
});
|
|
});
|
|
|
|
app.post('/api/auth/login', (req, res) => {
|
|
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' });
|
|
const user = sanitizeUser(req.body && req.body.user);
|
|
const password = req.body && req.body.password;
|
|
if (!user || !auth.verify(user, password)) return res.status(401).json({ error: 'Feil brukernavn eller passord' });
|
|
res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds()));
|
|
store.rememberUser(user);
|
|
res.json({ user });
|
|
});
|
|
|
|
app.post('/api/auth/logout', (req, res) => {
|
|
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Sett/endre eget passord. Krever innlogging (sesjon eller proxy-bypass).
|
|
// Har du allerede passord og er innlogget via sesjon, kreves nåværende passord.
|
|
app.post('/api/auth/password', (req, res) => {
|
|
if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' });
|
|
if (!req.authed) return res.status(401).json({ error: 'Ikke innlogget' });
|
|
const user = req.user;
|
|
if (auth.hasPassword(user) && req.authVia === 'session') {
|
|
if (!auth.verify(user, req.body && req.body.current)) {
|
|
return res.status(403).json({ error: 'Feil nåværende passord' });
|
|
}
|
|
}
|
|
try { auth.setPassword(user, req.body && req.body.password); }
|
|
catch (e) { return res.status(400).json({ error: e.message }); }
|
|
// Logg inn via sesjon umiddelbart (så proxy-auth kan skrus av etterpå).
|
|
res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(user), auth.ttlSeconds()));
|
|
res.json({ ok: true, user });
|
|
});
|
|
|
|
// ----- Guard: alt under her krever innlogging (kun i app-modus) -----
|
|
app.use((req, res, next) => {
|
|
if (req.authed) return next();
|
|
if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Ikke innlogget', login: true });
|
|
if (req.method === 'GET' && (req.headers.accept || '').includes('text/html')) return res.sendFile(LOGIN_PAGE);
|
|
return res.status(401).end();
|
|
});
|
|
|
|
// Statiske filer (innloggede brukere; login.html serveres separat over)
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
app.get('/api/state', (req, res) => {
|
|
store.rememberUser(req.user);
|
|
const today = todayStr();
|
|
const plan = store.getPlan(req.user, today);
|
|
res.json({
|
|
user: req.user,
|
|
canSetName: COOKIE_AUTH_ENABLED && authMode !== 'app',
|
|
auth: { mode: authMode, via: req.authVia, hasPassword: auth.hasPassword(req.user) },
|
|
tasks: store.list(),
|
|
activity: store.activity,
|
|
known_users: [...store.knownUsers].sort(),
|
|
online: onlineUsers(),
|
|
categories: store.categories(),
|
|
plan: { date: today, user: req.user, name: plan.name, items: plan.items, shared: plan.shared },
|
|
planIndex: store.planIndex(req.user),
|
|
});
|
|
});
|
|
|
|
app.get('/api/plan', (req, res) => {
|
|
const date = validDate(req.query.date) ? req.query.date : todayStr();
|
|
const owner = typeof req.query.user === 'string' && req.query.user ? sanitizeUser(req.query.user) : req.user;
|
|
const plan = store.getPlanFor(req.user, owner, date);
|
|
if (!plan) return res.status(403).json({ error: 'Planen er privat' });
|
|
res.json({ date, user: owner, name: plan.name, items: plan.items, shared: plan.shared, readonly: owner !== req.user });
|
|
});
|
|
|
|
// Plan-endringer påvirker per-oppgave-badges hos andre. Delte planer
|
|
// broadcastes til alle; private sendes kun til eierens egne faner (andre får
|
|
// en tom melding så de fjerner planen fra sin badge-indeks).
|
|
function broadcastPlan(owner, date) {
|
|
const plan = store.getPlan(owner, date);
|
|
const ownerMsg = JSON.stringify({ type: 'plan', user: owner, date, name: plan.name, items: plan.items, shared: plan.shared });
|
|
const othersMsg = plan.shared
|
|
? ownerMsg
|
|
: JSON.stringify({ type: 'plan', user: owner, date, name: '', items: [], shared: false });
|
|
for (const [ws, meta] of clients) {
|
|
if (ws.readyState !== ws.OPEN) continue;
|
|
ws.send(meta.user === owner ? ownerMsg : othersMsg);
|
|
}
|
|
}
|
|
|
|
app.put('/api/plan', (req, res) => {
|
|
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
|
const name = req.body && typeof req.body.name === 'string' ? req.body.name : undefined;
|
|
const shared = req.body && typeof req.body.shared === 'boolean' ? req.body.shared : undefined;
|
|
const plan = store.setPlan(req.user, date, req.body && req.body.items, name, shared);
|
|
broadcastPlan(req.user, date);
|
|
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
|
});
|
|
|
|
app.put('/api/plan/share', (req, res) => {
|
|
const date = validDate(req.body && req.body.date) ? req.body.date : todayStr();
|
|
const shared = !!(req.body && req.body.shared);
|
|
const plan = store.setPlanShared(req.user, date, shared);
|
|
broadcastPlan(req.user, date);
|
|
res.json({ date, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
|
});
|
|
|
|
app.put('/api/plan/move', (req, res) => {
|
|
const from = validDate(req.body && req.body.from) ? req.body.from : null;
|
|
const to = validDate(req.body && req.body.to) ? req.body.to : null;
|
|
if (!from || !to) return res.status(400).json({ error: 'Ugyldig dato (from/to må være YYYY-MM-DD)' });
|
|
const plan = store.movePlan(req.user, from, to);
|
|
broadcastPlan(req.user, from); // kildedatoen er nå tom
|
|
broadcastPlan(req.user, to); // måldatoen har planen
|
|
res.json({ date: to, user: req.user, name: plan.name, items: plan.items, shared: plan.shared });
|
|
});
|
|
|
|
app.post('/api/categories', (req, res) => {
|
|
const cat = store.addCategory({ name: req.body && req.body.name, color: req.body && req.body.color }, req.user);
|
|
broadcast({ type: 'categories', categories: store.categories(), by: req.user });
|
|
res.status(201).json({ category: cat });
|
|
});
|
|
|
|
app.post('/api/login', (req, res) => {
|
|
if (!COOKIE_AUTH_ENABLED) return res.status(403).json({ error: 'Navnevalg er deaktivert — auth styres av proxy' });
|
|
const raw = req.body && req.body.name;
|
|
// Ved eksplisitt navnevalg: avvis ugyldige tegn i stedet for å sanitere stille.
|
|
if (typeof raw !== 'string' || !/^[A-Za-z0-9._@\-]{1,64}$/.test(raw)) {
|
|
return res.status(400).json({ error: 'Ugyldig navn (a-z, 0-9, . _ @ -)' });
|
|
}
|
|
const name = raw;
|
|
res.setHeader('Set-Cookie', `netops_user=${encodeURIComponent(name)}; Path=/; Max-Age=31536000; SameSite=Lax`);
|
|
store.rememberUser(name);
|
|
res.json({ user: name });
|
|
});
|
|
|
|
app.post('/api/tasks', (req, res) => {
|
|
const { task, activity } = store.create(req.body, req.user);
|
|
broadcast({ type: 'task', op: 'create', task, by: req.user, activity });
|
|
res.status(201).json({ task });
|
|
});
|
|
|
|
app.put('/api/tasks/:id', (req, res) => {
|
|
// Valgfri `comment` følger med ved statusbytte; normalizeTask ignorerer
|
|
// ukjente felter, så den blir ikke en del av selve oppgaven.
|
|
const comment = req.body && typeof req.body.comment === 'string' ? req.body.comment : '';
|
|
const { task, activity } = store.update(req.params.id, req.body, req.user, { comment });
|
|
broadcast({ type: 'task', op: 'update', task, by: req.user, activity });
|
|
res.json({ task });
|
|
});
|
|
|
|
app.delete('/api/tasks/:id', (req, res) => {
|
|
const { task, activity } = store.remove(req.params.id, req.user);
|
|
broadcast({ type: 'task', op: 'delete', id: task.id, by: req.user, activity });
|
|
res.json({ ok: true, task });
|
|
});
|
|
|
|
app.post('/api/tasks/:id/comments', (req, res) => {
|
|
const { task, comment, activity } = store.addComment(req.params.id, req.body && req.body.text, req.user);
|
|
broadcast({ type: 'task', op: 'update', task, by: req.user, activity });
|
|
res.status(201).json({ task, comment });
|
|
});
|
|
|
|
app.delete('/api/tasks/:id/comments/:cid', (req, res) => {
|
|
const { task } = store.deleteComment(req.params.id, req.params.cid, req.user);
|
|
broadcast({ type: 'task', op: 'update', task, by: req.user });
|
|
res.json({ task });
|
|
});
|
|
|
|
app.post('/api/replace', (req, res) => {
|
|
const { count, activity } = store.replaceAll(req.body, req.user);
|
|
broadcast({ type: 'sync', tasks: store.list(), activity: store.activity, by: req.user });
|
|
res.json({ ok: true, count });
|
|
});
|
|
|
|
app.post('/api/seed', (req, res) => {
|
|
const seedFile = path.join(__dirname, 'seed.json');
|
|
let seed;
|
|
try { seed = JSON.parse(fs.readFileSync(seedFile, 'utf8')); }
|
|
catch { return res.status(500).json({ error: 'Mangler seed.json' }); }
|
|
const { count } = store.seed(seed, req.user);
|
|
broadcast({ type: 'sync', tasks: store.list(), activity: store.activity, by: req.user });
|
|
res.json({ ok: true, count });
|
|
});
|
|
|
|
// Feilhåndtering — oversetter store-exceptions til HTTP-koder.
|
|
app.use((err, req, res, next) => {
|
|
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
|
|
if (err instanceof ConflictError) return res.status(409).json({ error: err.message, task: err.task });
|
|
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ugyldig JSON' });
|
|
const code = err.status || 400;
|
|
res.status(code >= 500 ? 500 : code).json({ error: err.message || 'Ukjent feil' });
|
|
});
|
|
|
|
return { app, server, store, auth, wss, clients };
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const port = Number(process.env.PORT) || 3000;
|
|
const host = process.env.HOST || '0.0.0.0';
|
|
const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data');
|
|
const authMode = process.env.AUTH_MODE === 'app' ? 'app' : 'proxy';
|
|
const { server, store, auth } = createServer({ dataDir });
|
|
server.listen(port, host, () => {
|
|
console.log(`NetOps To-Do kjører på http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`);
|
|
console.log(`Datakatalog: ${dataDir} (${store.tasks.length} oppgaver)`);
|
|
if (authMode === 'app') {
|
|
console.log(`Auth: app-innlogging (innebygd). ${auth.anyPassword() ? '' : 'Ingen passord satt ennå — '}`
|
|
+ (auth.anyPassword() ? 'Sesjons-cookie aktiv.' : 'logg inn via proxy og sett passord under brukermenyen.'));
|
|
if (process.env.TRUST_PROXY_HEADER === '0') console.log(' Proxy-bypass er AV (TRUST_PROXY_HEADER=0).');
|
|
} else {
|
|
console.log('Auth: proxy-modus (X-Remote-User / Basic Auth / navn-cookie).');
|
|
if (!COOKIE_AUTH_ENABLED) console.log(' Navn-cookie er deaktivert — kun proxy-headere.');
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = { createServer, userFromRequest };
|