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.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/admin/i18n/de.json +100 -0
- package/admin/i18n/en.json +100 -0
- package/admin/i18n/es.json +100 -0
- package/admin/i18n/fr.json +100 -0
- package/admin/i18n/it.json +100 -0
- package/admin/i18n/nl.json +100 -0
- package/admin/i18n/pl.json +100 -0
- package/admin/i18n/pt.json +100 -0
- package/admin/i18n/ru.json +100 -0
- package/admin/i18n/uk.json +100 -0
- package/admin/i18n/zh-cn.json +100 -0
- package/admin/jsonConfig.json +668 -0
- package/admin/kanban.png +0 -0
- package/io-package.json +299 -0
- package/lib/cron.js +203 -0
- package/lib/dueat.js +77 -0
- package/lib/events.js +24 -0
- package/lib/holidays.js +167 -0
- package/lib/i18n-server.js +301 -0
- package/lib/notify.js +627 -0
- package/lib/scheduler.js +198 -0
- package/lib/server.js +799 -0
- package/lib/store.js +1500 -0
- package/main.js +382 -0
- package/package.json +58 -0
- package/www/css/app.css +1022 -0
- package/www/i18n/de.json +238 -0
- package/www/i18n/en.json +238 -0
- package/www/i18n/es.json +238 -0
- package/www/i18n/fr.json +238 -0
- package/www/i18n/it.json +238 -0
- package/www/i18n/nl.json +238 -0
- package/www/i18n/pl.json +238 -0
- package/www/i18n/pt.json +238 -0
- package/www/i18n/ru.json +238 -0
- package/www/i18n/uk.json +238 -0
- package/www/i18n/zh-cn.json +238 -0
- package/www/index.html +171 -0
- package/www/js/api.js +98 -0
- package/www/js/app.js +411 -0
- package/www/js/board.js +983 -0
- package/www/js/colorpicker.js +165 -0
- package/www/js/dialogs.js +1419 -0
- package/www/js/i18n.js +43 -0
- package/www/vendor/marked.min.js +69 -0
- package/www/vendor/purify.min.js +3 -0
- 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 };
|