Legg til Node/npm-versjon med sanntidssynk + Debian 13-oppsettsskript
Ny app i netops-todo-node/: Express + ws, WebSocket-push i stedet for polling, kanban med drag & drop, kommentarer, aktivitetslogg, optimistisk låsing, quick-add-parsing og presence. 12 integrasjonstester (node:test). setup-debian13.sh setter opp alt på en fersk LXC; README dekker Nginx Proxy Manager-oppsett. PHP-versjonen er uendret (kun handoff-peker). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
72a78b55fb
commit
6b9dbf8254
15 changed files with 3814 additions and 0 deletions
254
netops-todo-node/server.js
Normal file
254
netops-todo-node/server.js
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
'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.
|
||||
*
|
||||
* Brukernavn hentes fra (i denne rekkefølgen):
|
||||
* 1. X-Remote-User-header (satt av reverse proxy / oauth2-proxy o.l.)
|
||||
* 2. Authorization: Basic (Basic Auth terminert i proxy, brukernavn gjenbrukes)
|
||||
* 3. netops_user-cookie (selvvalgt navn — for hjemme/dev-bruk;
|
||||
* skru av med DISABLE_COOKIE_AUTH=1)
|
||||
* 4. 'anon'
|
||||
*/
|
||||
|
||||
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 COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1';
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function createServer({ dataDir = path.join(__dirname, 'data') } = {}) {
|
||||
const store = new Store(dataDir);
|
||||
store.load();
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use((req, res, next) => {
|
||||
req.user = userFromRequest(req);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
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; }
|
||||
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 = userFromRequest(req);
|
||||
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 ----------
|
||||
app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length }));
|
||||
|
||||
app.get('/api/state', (req, res) => {
|
||||
store.rememberUser(req.user);
|
||||
res.json({
|
||||
user: req.user,
|
||||
canSetName: COOKIE_AUTH_ENABLED,
|
||||
tasks: store.list(),
|
||||
activity: store.activity,
|
||||
known_users: [...store.knownUsers].sort(),
|
||||
online: onlineUsers(),
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
const { task, activity } = store.update(req.params.id, req.body, req.user);
|
||||
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, 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 { server, store } = 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 (!COOKIE_AUTH_ENABLED) console.log('Cookie-auth er deaktivert — bruker kun proxy-headere');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createServer, userFromRequest };
|
||||
Loading…
Add table
Add a link
Reference in a new issue