Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem

This commit is contained in:
2026-06-25 20:38:03 +02:00
parent 8eea117321
commit e96946e246
6 changed files with 964 additions and 4 deletions

View File

@@ -33,7 +33,7 @@ app.use((_req, res, next) => {
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
// iCal-Feeds werden serverseitig geprosxt → nur 'self' nötig
"connect-src 'self' https://web.archive.org",
"connect-src 'self' https://web.archive.org wss://dickendock.sermer.org ws://localhost:4000",
// data: für Avatare (base64), google.com + gstatic.com für Favicons im QuickLinks-Widget
"img-src 'self' data: https://www.google.com https://*.gstatic.com https://image.tmdb.org",
"worker-src 'self'",
@@ -127,7 +127,104 @@ app.get('/api/build-time', (_req, res) => {
} catch { res.json({ buildTime: 'unknown' }); }
});
app.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
const http = require('http');
const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws/whiteboard' });
// Raum-Map: whiteboard_id → Set<ws>
const rooms = new Map();
wss.on('connection', (ws, req) => {
// Token aus Query-String auslesen: /ws/whiteboard?token=...&id=...
const params = new URL(req.url, 'http://localhost').searchParams;
const token = params.get('token');
const wbId = parseInt(params.get('id'));
let userId = null;
try {
const p = jwt.verify(token, process.env.JWT_SECRET || 'dev-secret');
userId = p.id;
} catch {
ws.close(1008, 'Unauthorized');
return;
}
// Zugriff prüfen
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(wbId);
if (!wb) { ws.close(1008, 'Not found'); return; }
const isOwner = wb.owner_id === userId;
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(wbId, userId);
if (!isOwner && !perm) { ws.close(1008, 'Forbidden'); return; }
const role = isOwner ? 'owner' : perm.role;
ws.userId = userId;
ws.wbId = wbId;
ws.role = role;
ws.username = db.prepare('SELECT username FROM users WHERE id=?').get(userId)?.username || 'Unbekannt';
// Raum beitreten
if (!rooms.has(wbId)) rooms.set(wbId, new Set());
rooms.get(wbId).add(ws);
// Anderen im Raum mitteilen wer jointe
broadcast(wbId, { type: 'user_join', userId, username: ws.username, role }, ws);
// Aktive User-Liste an neuen Client schicken
const activeUsers = [...rooms.get(wbId)]
.filter(c => c !== ws && c.readyState === 1)
.map(c => ({ userId: c.userId, username: c.username, role: c.role }));
ws.send(JSON.stringify({ type: 'active_users', users: activeUsers }));
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case 'cursor':
// Cursor-Position live broadcasten (nur wenn nicht view-only)
broadcast(wbId, { type:'cursor', userId, username:ws.username, x:msg.x, y:msg.y }, ws);
break;
case 'elements':
// Canvas-Änderungen von Edit-Berechtigten an alle broadcasten
if (role === 'view') break;
broadcast(wbId, { type:'elements', userId, elements: msg.elements }, ws);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
}
});
ws.on('close', () => {
const room = rooms.get(wbId);
if (room) {
room.delete(ws);
if (room.size === 0) rooms.delete(wbId);
else broadcast(wbId, { type: 'user_leave', userId, username: ws.username });
}
});
ws.on('error', () => ws.terminate());
});
function broadcast(wbId, msg, exclude = null) {
const room = rooms.get(wbId);
if (!room) return;
const data = JSON.stringify(msg);
for (const client of room) {
if (client !== exclude && client.readyState === 1) {
client.send(data);
}
}
}
server.listen(4000, () => console.log('🚀 Dicken Dock läuft auf Port 4000'));
// ── Push-Zeitplaner Hintergrund-Job ──────────────────────────────────────────
const db = require('./db');

View File

@@ -0,0 +1,153 @@
const express = require('express');
const db = require('../../db');
const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router();
// ── DB-Migration ──────────────────────────────────────────────────────────────
(function migrate() {
db.exec(`
CREATE TABLE IF NOT EXISTS whiteboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
title TEXT NOT NULL DEFAULT 'Neues Whiteboard',
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
CREATE TABLE IF NOT EXISTS whiteboard_data (
whiteboard_id INTEGER PRIMARY KEY REFERENCES whiteboards(id) ON DELETE CASCADE,
elements TEXT NOT NULL DEFAULT '[]',
viewport TEXT NOT NULL DEFAULT '{"x":0,"y":0,"zoom":1}'
);
CREATE TABLE IF NOT EXISTS whiteboard_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
whiteboard_id INTEGER NOT NULL REFERENCES whiteboards(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'edit',
UNIQUE(whiteboard_id, user_id)
);
`);
})();
const uid = req => req.user.id;
// Hilfsfunktion: Hat User Zugriff? Gibt 'owner'|'edit'|'view'|null zurück
function access(whiteboardId, userId) {
const wb = db.prepare('SELECT owner_id FROM whiteboards WHERE id=?').get(whiteboardId);
if (!wb) return null;
if (wb.owner_id === userId) return 'owner';
const perm = db.prepare('SELECT role FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?').get(whiteboardId, userId);
return perm?.role || null;
}
// ── Liste ─────────────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
// Eigene + geteilte Whiteboards
const own = db.prepare(`
SELECT w.*, 'owner' as role, u.username as owner_name
FROM whiteboards w JOIN users u ON u.id=w.owner_id
WHERE w.owner_id=? ORDER BY w.updated_at DESC
`).all(me);
const shared = db.prepare(`
SELECT w.*, p.role, u.username as owner_name
FROM whiteboards w
JOIN whiteboard_permissions p ON p.whiteboard_id=w.id AND p.user_id=?
JOIN users u ON u.id=w.owner_id
ORDER BY w.updated_at DESC
`).all(me);
res.json({ whiteboards: [...own, ...shared] });
});
// ── Erstellen ─────────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const { title = 'Neues Whiteboard' } = req.body;
const me = uid(req);
const r = db.prepare("INSERT INTO whiteboards (owner_id, title) VALUES (?,?)").run(me, title.trim() || 'Neues Whiteboard');
db.prepare("INSERT INTO whiteboard_data (whiteboard_id) VALUES (?)").run(r.lastInsertRowid);
res.json(db.prepare('SELECT * FROM whiteboards WHERE id=?').get(r.lastInsertRowid));
});
// ── Umbenennen ────────────────────────────────────────────────────────────────
router.patch('/:id/title', authenticate, (req, res) => {
const { title } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Titel fehlt' });
const me = uid(req);
const role = access(req.params.id, me);
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare("UPDATE whiteboards SET title=?, updated_at=datetime('now','localtime') WHERE id=?").run(title.trim(), req.params.id);
res.json({ ok: true });
});
// ── Löschen ───────────────────────────────────────────────────────────────────
router.delete('/:id', authenticate, (req, res) => {
const me = uid(req);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann löschen' });
db.prepare('DELETE FROM whiteboards WHERE id=?').run(wb.id);
res.json({ ok: true });
});
// ── Canvas laden ──────────────────────────────────────────────────────────────
router.get('/:id/data', authenticate, (req, res) => {
const me = uid(req);
const role = access(req.params.id, me);
if (!role) return res.status(403).json({ error: 'Kein Zugriff' });
const data = db.prepare('SELECT * FROM whiteboard_data WHERE whiteboard_id=?').get(req.params.id);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
const perms = db.prepare(`
SELECT p.*, u.username FROM whiteboard_permissions p
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
`).all(req.params.id);
const owner = db.prepare('SELECT username FROM users WHERE id=?').get(wb.owner_id);
res.json({ ...data, role, title: wb.title, owner: owner.username, permissions: perms });
});
// ── Canvas speichern ──────────────────────────────────────────────────────────
// Feste Route vor :id-Routen
router.post('/:id/save', authenticate, (req, res) => {
const me = uid(req);
const role = access(req.params.id, me);
if (!role || role === 'view') return res.status(403).json({ error: 'Kein Schreibzugriff' });
const { elements, viewport } = req.body;
db.prepare(`
UPDATE whiteboard_data SET elements=?, viewport=? WHERE whiteboard_id=?
`).run(JSON.stringify(elements || []), JSON.stringify(viewport || {x:0,y:0,zoom:1}), req.params.id);
db.prepare("UPDATE whiteboards SET updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
res.json({ ok: true });
});
// ── Berechtigungen: alle User für Share-Modal ─────────────────────────────────
router.get('/users-list', authenticate, (req, res) => {
const me = uid(req);
const users = db.prepare('SELECT id, username FROM users WHERE id!=? ORDER BY username').all(me);
res.json({ users });
});
// ── Berechtigungen setzen ─────────────────────────────────────────────────────
router.put('/:id/permissions', authenticate, (req, res) => {
const me = uid(req);
const wb = db.prepare('SELECT * FROM whiteboards WHERE id=?').get(req.params.id);
if (!wb) return res.status(404).json({ error: 'Nicht gefunden' });
if (wb.owner_id !== me) return res.status(403).json({ error: 'Nur der Ersteller kann Berechtigungen vergeben' });
const { permissions } = req.body; // [{user_id, role: 'edit'|'view'|null}]
if (!Array.isArray(permissions)) return res.status(400).json({ error: 'permissions fehlt' });
const upsert = db.prepare("INSERT INTO whiteboard_permissions (whiteboard_id,user_id,role) VALUES (?,?,?) ON CONFLICT(whiteboard_id,user_id) DO UPDATE SET role=excluded.role");
const remove = db.prepare("DELETE FROM whiteboard_permissions WHERE whiteboard_id=? AND user_id=?");
const tx = db.transaction(() => {
for (const p of permissions) {
if (!p.user_id) continue;
if (p.role === null || p.role === 'none') remove.run(req.params.id, p.user_id);
else upsert.run(req.params.id, p.user_id, p.role);
}
});
tx();
const updated = db.prepare(`
SELECT p.*, u.username FROM whiteboard_permissions p
JOIN users u ON u.id=p.user_id WHERE p.whiteboard_id=?
`).all(req.params.id);
res.json({ permissions: updated });
});
module.exports = router;