iobroker.kanban 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/admin/i18n/de.json +100 -0
  4. package/admin/i18n/en.json +100 -0
  5. package/admin/i18n/es.json +100 -0
  6. package/admin/i18n/fr.json +100 -0
  7. package/admin/i18n/it.json +100 -0
  8. package/admin/i18n/nl.json +100 -0
  9. package/admin/i18n/pl.json +100 -0
  10. package/admin/i18n/pt.json +100 -0
  11. package/admin/i18n/ru.json +100 -0
  12. package/admin/i18n/uk.json +100 -0
  13. package/admin/i18n/zh-cn.json +100 -0
  14. package/admin/jsonConfig.json +668 -0
  15. package/admin/kanban.png +0 -0
  16. package/io-package.json +299 -0
  17. package/lib/cron.js +203 -0
  18. package/lib/dueat.js +77 -0
  19. package/lib/events.js +24 -0
  20. package/lib/holidays.js +167 -0
  21. package/lib/i18n-server.js +301 -0
  22. package/lib/notify.js +627 -0
  23. package/lib/scheduler.js +198 -0
  24. package/lib/server.js +799 -0
  25. package/lib/store.js +1500 -0
  26. package/main.js +382 -0
  27. package/package.json +58 -0
  28. package/www/css/app.css +1022 -0
  29. package/www/i18n/de.json +238 -0
  30. package/www/i18n/en.json +238 -0
  31. package/www/i18n/es.json +238 -0
  32. package/www/i18n/fr.json +238 -0
  33. package/www/i18n/it.json +238 -0
  34. package/www/i18n/nl.json +238 -0
  35. package/www/i18n/pl.json +238 -0
  36. package/www/i18n/pt.json +238 -0
  37. package/www/i18n/ru.json +238 -0
  38. package/www/i18n/uk.json +238 -0
  39. package/www/i18n/zh-cn.json +238 -0
  40. package/www/index.html +171 -0
  41. package/www/js/api.js +98 -0
  42. package/www/js/app.js +411 -0
  43. package/www/js/board.js +983 -0
  44. package/www/js/colorpicker.js +165 -0
  45. package/www/js/dialogs.js +1419 -0
  46. package/www/js/i18n.js +43 -0
  47. package/www/vendor/marked.min.js +69 -0
  48. package/www/vendor/purify.min.js +3 -0
  49. package/www/vendor/sortable.min.js +2 -0
package/lib/server.js ADDED
@@ -0,0 +1,799 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const path = require('node:path');
5
+ const fs = require('node:fs');
6
+ const express = require('express');
7
+ const { WebSocketServer, WebSocket } = require('ws');
8
+ const { TRASH_RETENTION_DAYS, nextCronDates } = require('./store');
9
+ const { validateCron } = require('./cron');
10
+ const { cardWithDueAt, boardWithDueAt } = require('./dueat');
11
+
12
+ /**
13
+ * Rein lesende Kommandos: für board-begrenzte Tokens auch ohne Board-Angabe erlaubt,
14
+ * weil Lesen am Adapter ohnehin nicht token-pflichtig ist. Alles andere ohne
15
+ * Board-Angabe ist für solche Tokens gesperrt (secure by default).
16
+ */
17
+ const READ_COMMANDS = new Set(['listBoards', 'getBoards', 'getBoard']);
18
+
19
+ /**
20
+ * Welche Body-Felder benennen bei einem Kommando das tatsaechlich betroffene Board?
21
+ * Nur diese Felder duerfen die Board-Bindung eines Tokens erfuellen. Sonst genuegt
22
+ * ein beliebiges erlaubtes Board irgendwo im Body, um sie auszuhebeln: `addBoard`
23
+ * etwa liest `board` gar nicht und legte trotzdem ein fremdes Board an.
24
+ * `source`/`target` spiegeln die Aufloesung in main.js handleCommand wider.
25
+ * Kommandos ohne Eintrag (addBoard, Unbekanntes) fassen kein bestimmtes Board an
26
+ * und bleiben board-begrenzten Tokens verwehrt.
27
+ */
28
+ const COMMAND_BOARDS = {
29
+ deleteBoard: { source: true },
30
+ addCard: { source: true },
31
+ updateCard: { source: true },
32
+ editCard: { source: true },
33
+ moveCard: { source: true },
34
+ doneCard: { source: true },
35
+ deleteCard: { source: true },
36
+ restoreCard: { source: true },
37
+ purgeCard: { source: true },
38
+ emptyTrash: { source: true },
39
+ transferCard: { source: true, target: true },
40
+ };
41
+
42
+ /**
43
+ * HTTP-Server des Adapters: statisches Frontend (www/), REST-API (/api),
44
+ * eingehende Webhooks (/webhook/:token) und WebSocket (/ws) für Live-Sync.
45
+ * Bewusst KEINE Frame-/CSP-Header, damit die iframe-Einbindung (Lovelace) frei ist.
46
+ */
47
+ class Server {
48
+ /**
49
+ * @param adapter ioBroker-Adapter
50
+ * @param store Store aus store.js
51
+ * @param handleCommand (cmd, payload, source) => Promise<any> — gemeinsamer Kommando-Kern
52
+ */
53
+ constructor(adapter, store, handleCommand) {
54
+ this.adapter = adapter;
55
+ this.store = store;
56
+ this.handleCommand = handleCommand;
57
+ this.server = null;
58
+ this.wss = null;
59
+ }
60
+
61
+ // Ausgabe-Aufbereitung: jedes Kartenobjekt der API traegt zusaetzlich das
62
+ // berechnete Feld dueAt (Faelligkeit inkl. Uhrzeit mit lokalem Offset).
63
+ _tz() {
64
+ return this.adapter._timezone || 'Europe/Berlin';
65
+ }
66
+ _pubCard(card) {
67
+ return cardWithDueAt(card, this._tz());
68
+ }
69
+ _pubBoard(board) {
70
+ return boardWithDueAt(board, this._tz());
71
+ }
72
+
73
+ async start() {
74
+ const cfg = this.adapter.config;
75
+ const app = express();
76
+ app.use(express.json({ limit: '1mb' }));
77
+
78
+ // CORS nur auf den Integrationsrouten und nur fuer ausdruecklich erlaubte
79
+ // Origins. Zuvor stand `Access-Control-Allow-Origin: *` auf allen Routen —
80
+ // damit konnte jede fremde Seite `GET /` cross-origin lesen und den dort im
81
+ // <meta>-Tag eingebetteten Schreib-Token abgreifen. Die Board-UI selbst
82
+ // braucht kein CORS (same-origin), die iframe-Einbindung ebenfalls nicht.
83
+ // Leere Liste (Standard) = kein CORS. Server-seitige Integrationen (Skripte,
84
+ // Node-RED, curl) sind davon nicht betroffen: CORS ist reine Browser-Mechanik.
85
+ const corsOrigins = String(cfg.corsOrigins || '')
86
+ .split(',')
87
+ .map(s => s.trim().replace(/\/+$/, ''))
88
+ .filter(Boolean);
89
+ const corsAny = corsOrigins.includes('*');
90
+ app.use(['/api', '/webhook'], (req, res, next) => {
91
+ const origin = req.get('Origin');
92
+ if (origin && (corsAny || corsOrigins.includes(origin.replace(/\/+$/, '')))) {
93
+ // Origin zurueckspiegeln statt `*`: bleibt gueltig, wenn spaeter
94
+ // Cookies dazukommen (Wildcard und Credentials schliessen sich aus).
95
+ res.setHeader('Access-Control-Allow-Origin', origin);
96
+ res.setHeader('Vary', 'Origin');
97
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS');
98
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Kanban-Token');
99
+ res.setHeader('Access-Control-Max-Age', '600');
100
+ }
101
+ if (req.method === 'OPTIONS') {
102
+ return res.sendStatus(204);
103
+ }
104
+ next();
105
+ });
106
+
107
+ // Board-UI mit eingebettetem Schreib-Token ausliefern (CSP-konform via <meta>, kein Inline-Script)
108
+ const indexHtml = path.join(__dirname, '..', 'www', 'index.html');
109
+ app.get(['/', '/index.html'], (req, res) => {
110
+ fs.readFile(indexHtml, 'utf8', (err, html) => {
111
+ if (err) {
112
+ return res.status(500).end();
113
+ }
114
+ const meta = `<meta name="kanban-token" content="${this.adapter._apiSecret || ''}">`;
115
+ res.type('html')
116
+ .set('Cache-Control', 'no-cache')
117
+ .send(html.replace('</head>', `${meta}\n</head>`));
118
+ });
119
+ });
120
+
121
+ // Schreibende /api-Zugriffe brauchen einen Token (Lesen bleibt offen).
122
+ app.use('/api', (req, res, next) => this._guardApiWrite(req, res, next));
123
+
124
+ this._apiRoutes(app);
125
+ this._webhookRoutes(app);
126
+
127
+ app.use(
128
+ '/',
129
+ express.static(path.join(__dirname, '..', 'www'), {
130
+ setHeaders: (res, filePath) => {
131
+ // SPA-Assets immer revalidieren → keine veralteten HTML/JS-Mischzustände
132
+ if (/\.(html|js|mjs|json|css)$/i.test(filePath)) {
133
+ res.setHeader('Cache-Control', 'no-cache');
134
+ }
135
+ },
136
+ }),
137
+ );
138
+
139
+ this.server = http.createServer(app);
140
+ this.wss = new WebSocketServer({ server: this.server, path: '/ws' });
141
+ this.wss.on('connection', ws => {
142
+ ws.isAlive = true;
143
+ ws.on('pong', () => {
144
+ ws.isAlive = true;
145
+ });
146
+ ws.on('error', () => {});
147
+ });
148
+ this._pingInterval = this.adapter.setInterval(() => {
149
+ for (const ws of this.wss.clients) {
150
+ if (!ws.isAlive) {
151
+ ws.terminate();
152
+ continue;
153
+ }
154
+ ws.isAlive = false;
155
+ ws.ping();
156
+ }
157
+ }, 30 * 1000);
158
+
159
+ // Store-Änderungen an alle offenen Ansichten broadcasten
160
+ this.store.onChange = (boardId, rev) => this.broadcast({ type: 'dirty', boardId, rev });
161
+
162
+ const port = await this.adapter.getPortAsync(cfg.port || 8095);
163
+ if (port !== (cfg.port || 8095)) {
164
+ // Gewolltes Ausweichverhalten, kein Defekt: als Warnung loggen, sonst
165
+ // erzeugt ioBroker eine Fehlerbenachrichtigung.
166
+ this.adapter.log.warn(
167
+ `Port ${cfg.port} is in use - falling back to free port ${port}. ` +
168
+ 'The instance list still shows the configured port; enter the free port there to keep both in sync.',
169
+ );
170
+ }
171
+ await new Promise((resolve, reject) => {
172
+ this.server.once('error', reject);
173
+ this.server.listen(port, cfg.bind || '0.0.0.0', () => resolve());
174
+ });
175
+ this.adapter.log.info(`Web server listening on ${cfg.bind || '0.0.0.0'}:${port}`);
176
+ return port;
177
+ }
178
+
179
+ broadcast(msg) {
180
+ if (!this.wss) {
181
+ return;
182
+ }
183
+ const data = JSON.stringify(msg);
184
+ for (const ws of this.wss.clients) {
185
+ if (ws.readyState === WebSocket.OPEN) {
186
+ ws.send(data);
187
+ }
188
+ }
189
+ }
190
+
191
+ async stop() {
192
+ if (this._pingInterval) {
193
+ this.adapter.clearInterval(this._pingInterval);
194
+ }
195
+ if (this.wss) {
196
+ for (const ws of this.wss.clients) {
197
+ ws.terminate();
198
+ }
199
+ this.wss.close();
200
+ }
201
+ if (this.server) {
202
+ await new Promise(resolve => this.server.close(resolve));
203
+ }
204
+ }
205
+
206
+ // ------------------------------------------------------------ REST-API
207
+
208
+ /**
209
+ * Schreibzugriffe (POST/PATCH/DELETE) auf /api brauchen den SPA-Token
210
+ * (in index.html als <meta> injiziert) oder einen gültigen inboundToken.
211
+ * GET/HEAD/OPTIONS bleiben offen. Abschaltbar über native.apiWriteProtection=false.
212
+ * Agenten-Tokens gelten hier mit derselben Board-Bindung wie am Webhook:
213
+ * ein auf Boards begrenzter Token darf über /api nichts anderes anfassen.
214
+ *
215
+ * @param req
216
+ * @param res
217
+ * @param next
218
+ */
219
+ _guardApiWrite(req, res, next) {
220
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
221
+ return next();
222
+ }
223
+ if (this.adapter.config.apiWriteProtection === false) {
224
+ return next();
225
+ }
226
+ // Bewusst kein Query-Parameter: Tokens in URLs landen in Logs, History und Referrern.
227
+ const tok = String(req.get('X-Kanban-Token') || (req.body && req.body._token) || '').trim();
228
+ if (!tok) {
229
+ this.adapter.log.warn(`Write access to ${req.originalUrl} without a token from ${req.ip}`);
230
+ return res.status(401).json({ error: 'write requires token' });
231
+ }
232
+ if (tok === this.adapter._apiSecret) {
233
+ return next();
234
+ }
235
+ const entry = this._findToken(tok);
236
+ if (entry) {
237
+ req.tokenEntry = entry;
238
+ return this._guardTokenScope(req, res, next);
239
+ }
240
+ this.adapter.log.warn(`Write access to ${req.originalUrl} without a valid token from ${req.ip}`);
241
+ return res.status(401).json({ error: 'write requires token' });
242
+ }
243
+
244
+ /**
245
+ * Token-Zeile zu einem Wert suchen. Zeilen ohne Token werden uebersprungen:
246
+ * sonst passt eine leer gelassene Tabellenzeile auf jeden Aufruf ohne Token
247
+ * und macht die schreibende API offen.
248
+ *
249
+ * @param value
250
+ */
251
+ _findToken(value) {
252
+ const tok = String(value || '').trim();
253
+ if (!tok) {
254
+ return null;
255
+ }
256
+ return (
257
+ (this.adapter.config.inboundTokens || []).find(
258
+ t => t && t.enabled !== false && String(t.token || '').trim() === tok,
259
+ ) || null
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Ist der Token auf bestimmte Boards begrenzt? Nur ein ausdrueckliches '*'
265
+ * heisst "alle Boards". Ein leeres Feld galt frueher ebenfalls als '*' — wer
266
+ * die Liste leerte, um Rechte zu entziehen, vergab sie damit fuer alles.
267
+ *
268
+ * @param entry
269
+ */
270
+ _tokenScoped(entry) {
271
+ return String((entry && entry.allowedBoards) || '').trim() !== '*';
272
+ }
273
+
274
+ /**
275
+ * Board-Bindung eines Agenten-Tokens auf den /api-Routen durchsetzen.
276
+ * Geprüft werden das Board aus dem Pfad (/api/boards/<id>/...) und alle
277
+ * Board-Angaben im Body (u. a. das Ziel beim Übertragen).
278
+ *
279
+ * @param req
280
+ * @param res
281
+ * @param next
282
+ */
283
+ _guardTokenScope(req, res, next) {
284
+ const entry = req.tokenEntry;
285
+ if (!this._tokenScoped(entry)) {
286
+ return next();
287
+ }
288
+
289
+ // Massgeblich ist allein die Route: Welches Board fasst dieser Aufruf an?
290
+ // Beliebige Board-Felder im Body werden hier bewusst NICHT ausgewertet —
291
+ // sonst genuegt ein erlaubtes Board irgendwo im Body, um die Begrenzung
292
+ // auszuhebeln (`POST /api/boards {"title":"X","board":"erlaubt"}` legte so
293
+ // ein fremdes Board an, weil createBoard das Feld gar nicht liest).
294
+ const targets = new Set();
295
+ const m = /^\/boards\/([^/?]+)/.exec(req.path || '');
296
+ if (m) {
297
+ let boardId;
298
+ try {
299
+ boardId = decodeURIComponent(m[1]);
300
+ } catch {
301
+ return res.status(400).json({ error: 'Ungueltige Board-ID in der Adresse' });
302
+ }
303
+ targets.add(boardId);
304
+ // Beim Uebertragen ist zusaetzlich das Zielboard betroffen. `targetBoard`
305
+ // liest die Route zwar nicht, wird aber mitgeprueft: zusaetzliche Ziele
306
+ // koennen nur strenger machen, nie die Bindung erfuellen.
307
+ if (/\/transfer$/.test(req.path)) {
308
+ const body = req.body || {};
309
+ for (const key of ['toBoard', 'targetBoard']) {
310
+ if (body[key]) {
311
+ targets.add(String(body[key]));
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ if (!targets.size) {
318
+ // Nicht board-bezogene Schreibrouten (Board anlegen, Benutzer/Avatare)
319
+ // bleiben begrenzten Tokens verwehrt.
320
+ this.adapter.log.warn(
321
+ `Token '${entry.name || '?'}' may not perform ${req.method} ${req.originalUrl} (limited to specific boards)`,
322
+ );
323
+ return res.status(403).json({ error: 'token is limited to specific boards' });
324
+ }
325
+ for (const boardId of targets) {
326
+ if (!this._boardAllowed(entry, boardId)) {
327
+ this.adapter.log.warn(
328
+ `Token '${entry.name || '?'}' may not modify board '${boardId}' (${req.method} ${req.originalUrl})`,
329
+ );
330
+ return res.status(403).json({ error: `Token darf Board '${boardId}' nicht ändern` });
331
+ }
332
+ }
333
+ return next();
334
+ }
335
+
336
+ _apiRoutes(app) {
337
+ const wrap = fn => (req, res) => {
338
+ Promise.resolve(fn(req, res)).catch(e => {
339
+ const code = /existiert nicht/.test(e.message) ? 404 : 400;
340
+ res.status(code).json({ error: e.message });
341
+ });
342
+ };
343
+
344
+ app.get(
345
+ '/api/config',
346
+ wrap(async (req, res) => {
347
+ const cfg = this.adapter.config;
348
+ res.json({
349
+ users: await this._publicUsers(),
350
+ themeDefault: cfg.themeDefault || 'auto',
351
+ accentColor: cfg.accentColor || '#7E57C2',
352
+ dateFormat: cfg.dateFormat || this.adapter.dateFormat || 'DD.MM.',
353
+ timeFormat: cfg.timeFormat || '24h',
354
+ language: this.adapter._language || 'en',
355
+ trashRetentionDays: TRASH_RETENTION_DAYS,
356
+ });
357
+ }),
358
+ );
359
+
360
+ // Cron-Muster prüfen: liefert Gültigkeit, erzwungene Uhrzeit und die nächsten
361
+ // Termine. Reine Rechenoperation, daher wie alle GET-Routen ohne Token.
362
+ app.get('/api/cron/check', (req, res) => {
363
+ const check = validateCron(req.query.expr || '');
364
+ if (!check.ok) {
365
+ return res.json({ ok: false, error: check.error });
366
+ }
367
+ res.json({ ok: true, time: check.time, next: nextCronDates(req.query.expr, 3) });
368
+ });
369
+
370
+ app.get('/api/custom.css', (req, res) => {
371
+ res.type('text/css').send(this.adapter.config.customCss || '');
372
+ });
373
+
374
+ app.get(
375
+ '/api/users',
376
+ wrap(async (req, res) => {
377
+ res.json(await this._publicUsers());
378
+ }),
379
+ );
380
+
381
+ // ---- Benutzer-Avatare (Bild-Upload; abgelegt im ioBroker-Dateispeicher) ----
382
+ app.get(
383
+ '/avatars/:name',
384
+ wrap(async (req, res) => {
385
+ const name = String(req.params.name).replace(/[^a-z0-9_-]/gi, '');
386
+ try {
387
+ const data = await this.adapter.readFileAsync(this.adapter.namespace, `avatars/${name}.png`);
388
+ const buf = data && data.file !== undefined ? data.file : data;
389
+ res.type('image/png')
390
+ .set('Cache-Control', 'no-cache')
391
+ .send(Buffer.isBuffer(buf) ? buf : Buffer.from(buf));
392
+ } catch {
393
+ res.status(404).end();
394
+ }
395
+ }),
396
+ );
397
+
398
+ app.post(
399
+ '/api/users/:name/avatar',
400
+ wrap(async (req, res) => {
401
+ const name = String(req.params.name);
402
+ if (!(this.adapter.config.users || []).some(u => u.name === name)) {
403
+ return res.status(404).json({ error: 'Benutzer unbekannt' });
404
+ }
405
+ const m = /^data:image\/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$/.exec((req.body || {}).image || '');
406
+ if (!m) {
407
+ return res.status(400).json({ error: 'Ungültiges Bild' });
408
+ }
409
+ const buf = Buffer.from(m[2], 'base64');
410
+ if (buf.length > 512 * 1024) {
411
+ return res.status(413).json({ error: 'Bild zu groß' });
412
+ }
413
+ await this.adapter.writeFileAsync(
414
+ this.adapter.namespace,
415
+ `avatars/${name.replace(/[^a-z0-9_-]/gi, '')}.png`,
416
+ buf,
417
+ );
418
+ res.json({ ok: true });
419
+ }),
420
+ );
421
+
422
+ app.delete(
423
+ '/api/users/:name/avatar',
424
+ wrap(async (req, res) => {
425
+ const name = String(req.params.name).replace(/[^a-z0-9_-]/gi, '');
426
+ try {
427
+ await this.adapter.delFileAsync(this.adapter.namespace, `avatars/${name}.png`);
428
+ } catch {
429
+ /* egal */
430
+ }
431
+ res.json({ ok: true });
432
+ }),
433
+ );
434
+
435
+ // Benutzer-Farbe setzen (Laufzeit; im ioBroker-Dateispeicher, ohne Adapter-Neustart)
436
+ app.patch(
437
+ '/api/users/:name',
438
+ wrap(async (req, res) => {
439
+ const name = String(req.params.name);
440
+ if (!(this.adapter.config.users || []).some(u => u.name === name)) {
441
+ return res.status(404).json({ error: 'Benutzer unbekannt' });
442
+ }
443
+ const body = req.body || {};
444
+ const colors = await this._readUserColors();
445
+ if (body.color !== undefined) {
446
+ const c = String(body.color);
447
+ if (!/^#[0-9a-fA-F]{3,8}$/.test(c)) {
448
+ return res.status(400).json({ error: 'Ungueltige Farbe' });
449
+ }
450
+ colors[name] = c;
451
+ }
452
+ await this._writeUserColors(colors);
453
+ res.json({ ok: true });
454
+ }),
455
+ );
456
+
457
+ app.get('/api/boards', (req, res) => res.json(this.store.listBoards()));
458
+
459
+ app.post(
460
+ '/api/boards',
461
+ wrap(async (req, res) => {
462
+ const board = await this.store.createBoard(req.body || {});
463
+ res.status(201).json(board);
464
+ }),
465
+ );
466
+
467
+ app.get(
468
+ '/api/boards/:id',
469
+ wrap(async (req, res) => {
470
+ const board = this.store.getBoard(req.params.id);
471
+ if (!board) {
472
+ return res.status(404).json({ error: `Board '${req.params.id}' existiert nicht` });
473
+ }
474
+ if (req.query.rev !== undefined && Number(req.query.rev) === board.rev) {
475
+ return res.json({ unchanged: true, rev: board.rev });
476
+ }
477
+ res.json(this._pubBoard(board));
478
+ }),
479
+ );
480
+
481
+ app.patch(
482
+ '/api/boards/:id',
483
+ wrap(async (req, res) => {
484
+ res.json(this._pubBoard(this.store.updateBoard(req.params.id, req.body || {})));
485
+ }),
486
+ );
487
+
488
+ app.delete(
489
+ '/api/boards/:id',
490
+ wrap(async (req, res) => {
491
+ await this.store.deleteBoard(req.params.id, (req.body && req.body.by) || 'api');
492
+ res.json({ ok: true });
493
+ }),
494
+ );
495
+
496
+ app.post(
497
+ '/api/boards/:id/cards',
498
+ wrap(async (req, res) => {
499
+ const card = this.store.addCard(req.params.id, req.body || {}, (req.body && req.body.by) || 'api');
500
+ res.status(201).json(this._pubCard(card));
501
+ }),
502
+ );
503
+
504
+ app.patch(
505
+ '/api/boards/:id/cards/:cardId',
506
+ wrap(async (req, res) => {
507
+ res.json(
508
+ this._pubCard(
509
+ this.store.updateCard(
510
+ req.params.id,
511
+ req.params.cardId,
512
+ req.body || {},
513
+ (req.body && req.body.by) || 'api',
514
+ ),
515
+ ),
516
+ );
517
+ }),
518
+ );
519
+
520
+ app.post(
521
+ '/api/boards/:id/cards/:cardId/move',
522
+ wrap(async (req, res) => {
523
+ const { columnId, order, by } = req.body || {};
524
+ res.json(
525
+ this._pubCard(this.store.moveCard(req.params.id, req.params.cardId, columnId, order, by || 'api')),
526
+ );
527
+ }),
528
+ );
529
+
530
+ app.delete(
531
+ '/api/boards/:id/cards/:cardId',
532
+ wrap(async (req, res) => {
533
+ res.json(this._pubCard(this.store.deleteCard(req.params.id, req.params.cardId, 'api')));
534
+ }),
535
+ );
536
+
537
+ // Papierkorb: Wiederherstellen, endgültig löschen, leeren
538
+ app.post(
539
+ '/api/boards/:id/cards/:cardId/restore',
540
+ wrap(async (req, res) => {
541
+ const { columnId, by } = req.body || {};
542
+ res.json(
543
+ this._pubCard(this.store.restoreCard(req.params.id, req.params.cardId, columnId, by || 'api')),
544
+ );
545
+ }),
546
+ );
547
+
548
+ app.post(
549
+ '/api/boards/:id/cards/:cardId/purge',
550
+ wrap(async (req, res) => {
551
+ res.json(
552
+ this._pubCard(
553
+ this.store.purgeCard(req.params.id, req.params.cardId, (req.body && req.body.by) || 'api'),
554
+ ),
555
+ );
556
+ }),
557
+ );
558
+
559
+ app.post(
560
+ '/api/boards/:id/trash/empty',
561
+ wrap(async (req, res) => {
562
+ res.json(this.store.emptyTrash(req.params.id, (req.body && req.body.by) || 'api'));
563
+ }),
564
+ );
565
+
566
+ // Karte auf ein anderes Board kopieren/verschieben
567
+ app.post(
568
+ '/api/boards/:id/cards/:cardId/transfer',
569
+ wrap(async (req, res) => {
570
+ const { toBoard, toColumn, mode, assignees, by } = req.body || {};
571
+ res.json(
572
+ this._pubCard(
573
+ this.store.transferCard(
574
+ req.params.id,
575
+ req.params.cardId,
576
+ toBoard,
577
+ toColumn,
578
+ mode === 'copy' ? 'copy' : 'move',
579
+ { assignees },
580
+ by || 'api',
581
+ ),
582
+ ),
583
+ );
584
+ }),
585
+ );
586
+ }
587
+
588
+ // ------------------------------------------------------------ Eingehende Webhooks
589
+
590
+ _checkToken(req) {
591
+ return this._findToken(req.params.token);
592
+ }
593
+
594
+ _boardAllowed(entry, boardId) {
595
+ const s = String((entry && entry.allowedBoards) || '').trim();
596
+ if (s === '*') {
597
+ return true;
598
+ }
599
+ if (!s) {
600
+ return false;
601
+ } // leere Liste = kein Board, nicht alle
602
+ return s.split(/[\s,;]+/).includes(boardId);
603
+ }
604
+
605
+ /**
606
+ * Darf dieser Token das Kommando ausführen? Prüft jede Board-Angabe im Body
607
+ * (board, boardId, toBoard, targetBoard). Nennt ein schreibendes Kommando gar
608
+ * kein Board — etwa addBoard —, ist es für einen auf Boards begrenzten Token
609
+ * gesperrt, weil sich die Begrenzung sonst umgehen liesse.
610
+ *
611
+ * @param entry
612
+ * @param body
613
+ * @returns null wenn erlaubt, sonst { code, error }
614
+ */
615
+ checkActionCommand(entry, body) {
616
+ body = body || {};
617
+ if (!this._tokenScoped(entry)) {
618
+ return null;
619
+ }
620
+
621
+ const cmd = String(body.cmd || '');
622
+ // Lesen ist am Adapter ohnehin nicht token-pflichtig.
623
+ if (READ_COMMANDS.has(cmd)) {
624
+ return null;
625
+ }
626
+
627
+ const spec = COMMAND_BOARDS[cmd];
628
+ if (!spec) {
629
+ return { code: 403, error: 'token is limited to specific boards' };
630
+ }
631
+ const targets = [];
632
+ if (spec.source) {
633
+ targets.push(body.board || body.boardId);
634
+ }
635
+ if (spec.target) {
636
+ targets.push(body.toBoard || body.targetBoard);
637
+ }
638
+ for (const raw of targets) {
639
+ // Fehlt das massgebliche Board, laesst sich die Bindung nicht pruefen.
640
+ if (!raw) {
641
+ return { code: 403, error: 'token is limited to specific boards' };
642
+ }
643
+ const boardId = String(raw);
644
+ if (!this._boardAllowed(entry, boardId)) {
645
+ return { code: 403, error: `Token darf Board '${boardId}' nicht ändern` };
646
+ }
647
+ }
648
+ return null;
649
+ }
650
+
651
+ /**
652
+ * Benutzerliste für die Oberfläche. Zeilen ohne ID werden verworfen — sie
653
+ * erscheinen sonst als Mitglied „null" und lassen sich nicht sinnvoll zuweisen.
654
+ */
655
+ async _publicUsers() {
656
+ const colors = await this._readUserColors();
657
+ const rows = (this.adapter.config.users || []).filter(u => u && String(u.name || '').trim());
658
+ return Promise.all(
659
+ rows.map(async u => ({
660
+ name: u.name,
661
+ displayName: u.displayName,
662
+ color: colors[u.name] || u.color || this._defaultColor(u.name),
663
+ avatar: await this._avatarExists(u.name),
664
+ })),
665
+ );
666
+ }
667
+
668
+ async _avatarExists(name) {
669
+ const n = String(name).replace(/[^a-z0-9_-]/gi, '');
670
+ try {
671
+ return !!(await this.adapter.fileExistsAsync(this.adapter.namespace, `avatars/${n}.png`));
672
+ } catch {
673
+ return false;
674
+ }
675
+ }
676
+
677
+ async _readUserColors() {
678
+ try {
679
+ const data = await this.adapter.readFileAsync(this.adapter.namespace, 'usercolors.json');
680
+ const buf = data && data.file !== undefined ? data.file : data;
681
+ return JSON.parse((Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).toString('utf8')) || {};
682
+ } catch {
683
+ return {};
684
+ }
685
+ }
686
+
687
+ async _writeUserColors(map) {
688
+ await this.adapter.writeFileAsync(
689
+ this.adapter.namespace,
690
+ 'usercolors.json',
691
+ Buffer.from(JSON.stringify(map), 'utf8'),
692
+ );
693
+ }
694
+
695
+ _defaultColor(name) {
696
+ const pal = [
697
+ '#e91e63',
698
+ '#2196f3',
699
+ '#00bcd4',
700
+ '#4caf50',
701
+ '#ff9800',
702
+ '#9c27b0',
703
+ '#f44336',
704
+ '#3f51b5',
705
+ '#009688',
706
+ '#795548',
707
+ ];
708
+ let h = 0;
709
+ const s = String(name || '');
710
+ for (let i = 0; i < s.length; i++) {
711
+ h = (h * 31 + s.charCodeAt(i)) >>> 0;
712
+ }
713
+ return pal[h % pal.length];
714
+ }
715
+
716
+ _webhookRoutes(app) {
717
+ const router = express.Router({ mergeParams: true });
718
+
719
+ router.use((req, res, next) => {
720
+ const entry = this._checkToken(req);
721
+ if (!entry) {
722
+ this.adapter.log.warn(`Webhook with an invalid token from ${req.ip}`);
723
+ return res.status(401).json({ error: 'invalid token' });
724
+ }
725
+ req.tokenEntry = entry;
726
+ next();
727
+ });
728
+
729
+ const wrap = fn => (req, res) => {
730
+ Promise.resolve(fn(req, res)).catch(e => {
731
+ const code = /existiert nicht/.test(e.message) ? 404 : 400;
732
+ res.status(code).json({ error: e.message });
733
+ });
734
+ };
735
+
736
+ const guardBoard = (req, res, boardId) => {
737
+ if (!this._boardAllowed(req.tokenEntry, boardId)) {
738
+ res.status(403).json({ error: `Token darf Board '${boardId}' nicht ändern` });
739
+ return false;
740
+ }
741
+ return true;
742
+ };
743
+
744
+ router.post(
745
+ '/boards/:id/cards',
746
+ wrap(async (req, res) => {
747
+ if (!guardBoard(req, res, req.params.id)) {
748
+ return;
749
+ }
750
+ const source = `webhook:${req.tokenEntry.name || 'token'}`;
751
+ res.status(201).json(this._pubCard(this.store.addCard(req.params.id, req.body || {}, source)));
752
+ }),
753
+ );
754
+
755
+ const updateHandler = wrap(async (req, res) => {
756
+ if (!guardBoard(req, res, req.params.id)) {
757
+ return;
758
+ }
759
+ const source = `webhook:${req.tokenEntry.name || 'token'}`;
760
+ res.json(this._pubCard(this.store.updateCard(req.params.id, req.params.cardId, req.body || {}, source)));
761
+ });
762
+ router.patch('/boards/:id/cards/:cardId', updateHandler);
763
+ router.post('/boards/:id/cards/:cardId', updateHandler);
764
+
765
+ router.post(
766
+ '/boards/:id/cards/:cardId/move',
767
+ wrap(async (req, res) => {
768
+ if (!guardBoard(req, res, req.params.id)) {
769
+ return;
770
+ }
771
+ const { columnId, order } = req.body || {};
772
+ const source = `webhook:${req.tokenEntry.name || 'token'}`;
773
+ res.json(this._pubCard(this.store.moveCard(req.params.id, req.params.cardId, columnId, order, source)));
774
+ }),
775
+ );
776
+
777
+ // Generische Aktion: gleiches Kommando-Vokabular wie sendTo/action-State
778
+ router.post(
779
+ '/action',
780
+ wrap(async (req, res) => {
781
+ const body = req.body || {};
782
+ const denied = this.checkActionCommand(req.tokenEntry, body);
783
+ if (denied) {
784
+ this.adapter.log.warn(
785
+ `Token '${req.tokenEntry.name || '?'}' denied for command '${body.cmd}': ${denied.error}`,
786
+ );
787
+ return res.status(denied.code).json({ error: denied.error });
788
+ }
789
+ const source = `webhook:${req.tokenEntry.name || 'token'}`;
790
+ const result = await this.handleCommand(body.cmd, body, source);
791
+ res.json(result === undefined ? { ok: true } : result);
792
+ }),
793
+ );
794
+
795
+ app.use('/webhook/:token', router);
796
+ }
797
+ }
798
+
799
+ module.exports = { Server };