Whiteboard: neues Tool mit Canvas, Undo, Kollaboration via WebSocket, Berechtigungssystem
This commit is contained in:
@@ -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');
|
||||
|
||||
153
backend/src/tools/whiteboard/routes.js
Normal file
153
backend/src/tools/whiteboard/routes.js
Normal 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;
|
||||
Reference in New Issue
Block a user