'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 }; } // Admin = lagret admin-rolle, ELLER proxy-bypass uten konto (bootstrap). function isAdminReq(req) { if (!req.authed) return false; if (auth.isAdmin(req.user)) return true; return req.authVia === 'proxy' && !auth.exists(req.user); } 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, role: req.authed ? (auth.role(req.user) || (isAdminReq(req) ? 'admin' : 'standard')) : null, isAdmin: isAdminReq(req), }); }); 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 }); }); // Logg ut alle enheter: bump epoke (tilbakekaller alle tokens), behold denne. app.post('/api/auth/logout-all', (req, res) => { if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' }); if (!req.authed || !auth.exists(req.user)) return res.status(401).json({ error: 'Ikke innlogget' }); auth.bumpEpoch(req.user); res.setHeader('Set-Cookie', sessionCookie(req, auth.makeToken(req.user), auth.ttlSeconds())); 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. // Ny bruker via proxy-bypass blir admin (bootstrap av første konto). 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' }); } } const bootstrapRole = (!auth.exists(user) && req.authVia === 'proxy') ? 'admin' : undefined; try { auth.setPassword(user, req.body && req.body.password, bootstrapRole); } 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, role: auth.role(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), role: auth.role(req.user) || (isAdminReq(req) ? 'admin' : 'standard'), isAdmin: isAdminReq(req), }, 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), }); }); // ----- Kontoadministrasjon (kun admin, kun app-modus) ----- function requireAdmin(req, res, next) { if (authMode !== 'app') return res.status(400).json({ error: 'App-innlogging er ikke aktivert' }); if (!isAdminReq(req)) return res.status(403).json({ error: 'Krever administrator' }); next(); } app.get('/api/auth/users', requireAdmin, (req, res) => res.json({ users: auth.listUsers() })); app.post('/api/auth/users', requireAdmin, (req, res) => { const user = sanitizeUser(req.body && req.body.user); if (!user) return res.status(400).json({ error: 'Ugyldig brukernavn (a-z, 0-9, . _ @ -)' }); const role = req.body && req.body.role === 'admin' ? 'admin' : 'standard'; try { auth.createUser(user, req.body && req.body.password, role); } catch (e) { return res.status(400).json({ error: e.message }); } store.rememberUser(user); res.status(201).json({ user, role }); }); app.post('/api/auth/users/:user/password', requireAdmin, (req, res) => { const user = sanitizeUser(req.params.user); if (!auth.exists(user)) return res.status(404).json({ error: 'Ukjent bruker' }); // Admin-reset bumper epoke implisitt (setPassword) → logger ut den brukeren. try { auth.setPassword(user, req.body && req.body.password); } catch (e) { return res.status(400).json({ error: e.message }); } res.json({ ok: true, user }); }); app.put('/api/auth/users/:user/role', requireAdmin, (req, res) => { const user = sanitizeUser(req.params.user); try { auth.setRole(user, req.body && req.body.role); } catch (e) { return res.status(400).json({ error: e.message }); } res.json({ ok: true, user, role: auth.role(user) }); }); app.delete('/api/auth/users/:user', requireAdmin, (req, res) => { const user = sanitizeUser(req.params.user); if (user === req.user) return res.status(400).json({ error: 'Du kan ikke slette din egen konto' }); try { auth.deleteUser(user); } catch (e) { return res.status(400).json({ error: e.message }); } res.json({ ok: true }); }); 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 };