tassello 0.2.0 → 0.4.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 (56) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +175 -8
  3. package/dist/server/behavior_tree_files.d.ts +0 -1
  4. package/dist/server/behavior_tree_files.js +2 -25
  5. package/dist/server/diagnostics.d.ts +16 -0
  6. package/dist/server/diagnostics.js +200 -0
  7. package/dist/server/diagnostics_state.d.ts +23 -0
  8. package/dist/server/diagnostics_state.js +146 -0
  9. package/dist/server/disks.d.ts +14 -0
  10. package/dist/server/disks.js +152 -0
  11. package/dist/server/file_manager.d.ts +25 -0
  12. package/dist/server/file_manager.js +717 -0
  13. package/dist/server/http.js +248 -5
  14. package/dist/server/netpbm.d.ts +3 -0
  15. package/dist/server/netpbm.js +69 -0
  16. package/dist/server/networking.d.ts +41 -0
  17. package/dist/server/networking.js +508 -0
  18. package/dist/server/networking_format.d.ts +38 -0
  19. package/dist/server/networking_format.js +651 -0
  20. package/dist/server/pty.d.ts +26 -0
  21. package/dist/server/pty.js +150 -0
  22. package/dist/server/server.js +13 -2
  23. package/dist/server/session.d.ts +10 -1
  24. package/dist/server/session.js +88 -1
  25. package/dist/server/terminal_output.d.ts +25 -0
  26. package/dist/server/terminal_output.js +108 -0
  27. package/dist/server/terminals.d.ts +32 -0
  28. package/dist/server/terminals.js +366 -0
  29. package/dist/server/util.d.ts +4 -0
  30. package/dist/server/util.js +45 -1
  31. package/dist/shared/file_manager.d.ts +130 -0
  32. package/dist/shared/file_manager.js +7 -0
  33. package/dist/shared/networking.d.ts +178 -0
  34. package/dist/shared/networking.js +248 -0
  35. package/dist/shared/protocol.d.ts +103 -2
  36. package/dist/shared/protocol.js +8 -0
  37. package/dist/types/client/api.d.ts +22 -0
  38. package/dist/types/client/connection.d.ts +30 -1
  39. package/dist/types/client/icons.d.ts +33 -0
  40. package/dist/types/client/sdk/index.d.ts +5 -3
  41. package/dist/types/shared/file_manager.d.ts +130 -0
  42. package/dist/types/shared/networking.d.ts +178 -0
  43. package/dist/types/shared/protocol.d.ts +103 -2
  44. package/dist/www/assets/Joystick.js +1 -1
  45. package/dist/www/assets/RobotModelView.js +2 -2
  46. package/dist/www/assets/TerminalView.css +1 -0
  47. package/dist/www/assets/TerminalView.js +17 -0
  48. package/dist/www/assets/dialogs.js +1 -0
  49. package/dist/www/assets/icons.js +1 -1
  50. package/dist/www/assets/index.css +1 -1
  51. package/dist/www/assets/index.js +59 -19
  52. package/dist/www/assets/keys.js +3 -0
  53. package/dist/www/assets/shared/tassello-sdk.js +1 -1
  54. package/dist/www/index.html +2 -0
  55. package/docs/plugins.md +22 -0
  56. package/package.json +6 -2
@@ -17,13 +17,32 @@
17
17
  // POST /api/uploads an image or a behavior tree (the body) -> {name}
18
18
  // GET /api/uploads/<name> an uploaded file
19
19
  // GET /api/plugins [PluginInfo] of the plugins loaded
20
+ // /api/fs/... the file manager's: any file of the
21
+ // robot (see src/shared/file_manager.ts), changed only by the
22
+ // dashboard's pages
23
+ // GET places FsPlaces: home, the ROS workspaces
24
+ // GET list?path=&known= FsListing, 204 while still the known version
25
+ // GET details?path= FsDetails
26
+ // GET disks [FsDisk] of the robot's file systems
27
+ // GET analysis?path= FsAnalysis: what takes a folder's space
28
+ // GET text?path=&stamp= FsText, 204 while still of that stamp
29
+ // PUT text {path, text, base, create} -> FsSaved
30
+ // POST op FsOperation -> FsResult
31
+ // POST upload?path=&name=&conflict= the file (the body) -> FsResult
32
+ // GET download?path=[&path=...] a file, or folders and files as a .tar.gz
33
+ // GET view?path= an image, PGM and PPM ones as PNG
20
34
  // GET /plugins/<name>/<path> a file of the plugin's client folder
35
+ import { spawn } from 'node:child_process';
21
36
  import { createReadStream, promises as fs } from 'node:fs';
22
37
  import path from 'node:path';
23
38
  import { promisify } from 'node:util';
24
39
  import { gzip } from 'node:zlib';
40
+ import sharp from 'sharp';
25
41
  import { DASHBOARD_CLIENT_HEADER } from '../shared/protocol.js';
26
- import { fileKind, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload } from './files.js';
42
+ import { listDisks, spaceOf } from './disks.js';
43
+ import { analyzeFolder, archiveArguments, changeMode, deleteEntries, entryDetails, fsError, listFolder, listPlaces, makeFile, makeFolder, readText, receiveFile, renameEntry, resolvePath, saveText, transfer, uploadTarget, } from './file_manager.js';
44
+ import { fileKind, IMAGE_EXTENSIONS, isUploadName, listDirectory, MAX_UPLOAD_BYTES, resolveImagePath, saveUpload, } from './files.js';
45
+ import { decodeNetpbm, NETPBM_EXTENSIONS } from './netpbm.js';
27
46
  import { HttpError } from './panels.js';
28
47
  import { errorMessage, packageShareDirectory } from './util.js';
29
48
  // By lowercase extension: meshes come as .STL as often as .stl
@@ -50,12 +69,24 @@ const CONTENT_TYPES = {
50
69
  // Worth compressing: text, and meshes, often tens of MB of CAD exports
51
70
  const COMPRESSIBLE = new Set(['.html', '.js', '.css', '.json', '.map', '.svg', '.stl', '.dae', '.obj']);
52
71
  const MAX_BODY_BYTES = 1024 * 1024;
72
+ // A text file saved by the file manager, 5 MB, as JSON
73
+ const MAX_TEXT_BODY_BYTES = 12 * 1024 * 1024;
74
+ // Of a file uploaded by the file manager: rosbags are large
75
+ const MAX_FS_UPLOAD_BYTES = 64 * 1024 ** 3;
76
+ // PGM and PPM images converted to PNG
77
+ const MAX_PREVIEW_BYTES = 256 * 1024 * 1024;
78
+ // Analyses at once: each reads a whole tree of folders
79
+ const MAX_ANALYSES = 2;
80
+ const CONFLICTS = ['ask', 'skip', 'keep-both', 'replace'];
53
81
  // Compressed files are kept, the most recent first, within this size
54
82
  const GZIP_CACHE_BYTES = 64 * 1024 * 1024;
55
83
  const PACKAGE_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
56
84
  // For the images of the robot and of the users: an SVG opened by itself must
57
85
  // not run scripts in the dashboard's origin
58
- const SANDBOXED = { 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox" };
86
+ const SANDBOXED = {
87
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
88
+ 'X-Content-Type-Options': 'nosniff',
89
+ };
59
90
  export function createRequestHandler(services) {
60
91
  return (req, res) => {
61
92
  handle(services, req, res).catch((e) => {
@@ -93,7 +124,7 @@ async function handle(services, req, res) {
93
124
  await serveFile(services.wwwDir, pathname === '/' ? 'index.html' : decodeURIComponent(pathname), req, res);
94
125
  }
95
126
  }
96
- async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
127
+ async function api({ store, topics, uploadsDir, plugins, logger }, req, res, route) {
97
128
  const origin = req.headers[DASHBOARD_CLIENT_HEADER.toLowerCase()];
98
129
  const author = typeof origin === 'string' && origin ? origin : null;
99
130
  const [resource, id, ...rest] = route;
@@ -149,6 +180,9 @@ async function api({ store, topics, uploadsDir, plugins }, req, res, route) {
149
180
  }
150
181
  throw new HttpError(404, `No API at ${req.url}`);
151
182
  }
183
+ else if (resource === 'fs' && id && rest.length === 0) {
184
+ return fsApi(req, res, `${method} ${id}`, logger);
185
+ }
152
186
  else if (resource === 'uploads' && id === undefined && method === 'POST') {
153
187
  return sendJson(res, 201, { name: await saveUpload(uploadsDir, await readBody(req, MAX_UPLOAD_BYTES)) });
154
188
  }
@@ -184,8 +218,217 @@ async function readBody(req, limit) {
184
218
  }
185
219
  return Buffer.concat(chunks);
186
220
  }
187
- async function readJson(req) {
188
- return JSON.parse((await readBody(req, MAX_BODY_BYTES)).toString('utf8'));
221
+ async function readJson(req, limit = MAX_BODY_BYTES) {
222
+ return JSON.parse((await readBody(req, limit)).toString('utf8'));
223
+ }
224
+ let analyses = 0;
225
+ // The file manager's API (see file_manager.ts): route is the method and the
226
+ // action, "GET list"
227
+ async function fsApi(req, res, route, logger) {
228
+ const query = new URL(req.url ?? '/', 'http://localhost').searchParams;
229
+ if (!route.startsWith('GET ') && typeof req.headers[DASHBOARD_CLIENT_HEADER.toLowerCase()] !== 'string') {
230
+ // Another site's page cannot send the header: its browser would first
231
+ // ask the server, which does not allow it
232
+ throw new HttpError(403, 'The robot’s files are changed by the dashboard’s pages only');
233
+ }
234
+ switch (route) {
235
+ case 'GET places':
236
+ return sendJson(res, 200, listPlaces());
237
+ case 'GET list': {
238
+ const listing = await listFolder(resolvePath(query.get('path') ?? '~'));
239
+ return listing.version === query.get('known') ? sendNothing(res) : sendJson(res, 200, listing);
240
+ }
241
+ case 'GET details':
242
+ return sendJson(res, 200, await entryDetails(resolvePath(query.get('path'))));
243
+ case 'GET disks':
244
+ return sendJson(res, 200, await listDisks());
245
+ case 'GET analysis': {
246
+ if (analyses >= MAX_ANALYSES) {
247
+ throw new HttpError(429, 'Other folders are being analyzed: try again in a moment');
248
+ }
249
+ analyses++;
250
+ try {
251
+ return sendJson(res, 200, await analyzeFolder(resolvePath(query.get('path'))));
252
+ }
253
+ finally {
254
+ analyses--;
255
+ }
256
+ }
257
+ case 'GET text': {
258
+ const text = await readText(resolvePath(query.get('path')), query.get('stamp'));
259
+ return text ? sendJson(res, 200, text) : sendNothing(res);
260
+ }
261
+ case 'PUT text': {
262
+ const body = (await readJson(req, MAX_TEXT_BODY_BYTES));
263
+ if (typeof body.text !== 'string') {
264
+ throw new HttpError(400, 'text must be a string');
265
+ }
266
+ const file = resolvePath(body.path);
267
+ const saved = await saveText(file, body.text, typeof body.base === 'string' ? body.base : null, body.create === true);
268
+ if (!saved.conflict) {
269
+ logger.info(`File saved: ${file}`);
270
+ }
271
+ return sendJson(res, 200, saved);
272
+ }
273
+ case 'POST op': {
274
+ const result = await operate((await readJson(req)), logger);
275
+ return sendJson(res, 200, result);
276
+ }
277
+ case 'POST upload': {
278
+ const folder = resolvePath(query.get('path'));
279
+ const name = query.get('name') ?? '';
280
+ const conflict = CONFLICTS.find((value) => value === query.get('conflict')) ?? 'ask';
281
+ const length = Number(req.headers['content-length']);
282
+ const space = Number.isFinite(length) ? await spaceOf(folder) : null;
283
+ if (space && length > space.free) {
284
+ throw new HttpError(507, `Not enough space on ${space.mount}: ${(space.free / 1e9).toFixed(1)} GB free`);
285
+ }
286
+ const target = await uploadTarget(folder, name, conflict);
287
+ if (!target) {
288
+ req.resume();
289
+ return sendJson(res, 200, { paths: [], conflicts: [name] });
290
+ }
291
+ const size = await receiveFile(req, target, MAX_FS_UPLOAD_BYTES);
292
+ logger.info(`File uploaded: ${target} (${size} bytes)`);
293
+ return sendJson(res, 201, { paths: [target], conflicts: [] });
294
+ }
295
+ case 'GET download': {
296
+ const files = query.getAll('path').map((value) => resolvePath(value));
297
+ if (files.length === 0) {
298
+ throw new HttpError(400, 'No path');
299
+ }
300
+ const stat = files.length === 1
301
+ ? await fs.stat(files[0]).catch((e) => {
302
+ throw fsError(e, `Cannot download ${path.basename(files[0])}`);
303
+ })
304
+ : null;
305
+ return stat?.isFile() ? sendDownload(res, files[0], stat) : sendArchive(res, files, logger);
306
+ }
307
+ case 'GET view': {
308
+ const file = resolvePath(query.get('path'));
309
+ const extension = path.extname(file).toLowerCase();
310
+ if (!NETPBM_EXTENSIONS.has(extension)) {
311
+ if (!IMAGE_EXTENSIONS.has(extension) && extension !== '.ico') {
312
+ throw new HttpError(415, `Not an image: ${path.basename(file)}`);
313
+ }
314
+ return serveFile(path.dirname(file), path.basename(file), req, res, SANDBOXED);
315
+ }
316
+ const stat = await fs.stat(file).catch((e) => {
317
+ throw fsError(e, `Cannot read ${path.basename(file)}`);
318
+ });
319
+ if (stat.size > MAX_PREVIEW_BYTES) {
320
+ throw new HttpError(413, `Too large to preview: ${path.basename(file)}`);
321
+ }
322
+ let pixels;
323
+ try {
324
+ pixels = decodeNetpbm(await fs.readFile(file));
325
+ }
326
+ catch (e) {
327
+ throw e instanceof Error && !('code' in e) ? new HttpError(415, e.message) : fsError(e, `Cannot read ${path.basename(file)}`);
328
+ }
329
+ const { data, width, height, channels } = pixels;
330
+ const png = await sharp(data, { raw: { width, height, channels } }).png().toBuffer();
331
+ res.writeHead(200, {
332
+ ...SANDBOXED,
333
+ 'Content-Type': 'image/png',
334
+ 'Content-Length': png.length,
335
+ 'Cache-Control': 'no-cache',
336
+ });
337
+ res.end(png);
338
+ return;
339
+ }
340
+ }
341
+ throw new HttpError(404, `No API at ${req.url}`);
342
+ }
343
+ async function operate(body, logger) {
344
+ const paths = () => {
345
+ if (!Array.isArray(body.paths) || body.paths.length === 0) {
346
+ throw new HttpError(400, 'paths must be a list of paths');
347
+ }
348
+ return body.paths.map((value) => resolvePath(value));
349
+ };
350
+ const log = (what, files) => logger.info(`File manager: ${what} ${files.length === 1 ? files[0] : `${files.length} entries (${files[0]}, …)`}`);
351
+ switch (body.op) {
352
+ case 'mkdir':
353
+ return makeFolder(resolvePath(body.path));
354
+ case 'create':
355
+ return makeFile(resolvePath(body.path));
356
+ case 'rename': {
357
+ const file = resolvePath(body.path);
358
+ const result = await renameEntry(file, body.name);
359
+ log(`renamed to ${body.name}:`, [file]);
360
+ return result;
361
+ }
362
+ case 'move':
363
+ case 'copy': {
364
+ const sources = paths();
365
+ const conflict = CONFLICTS.find((value) => value === body.conflict) ?? 'ask';
366
+ const result = await transfer(body.op, sources, resolvePath(body.to), conflict);
367
+ if (result.paths.length > 0) {
368
+ log(`${body.op === 'move' ? 'moved' : 'copied'} into ${resolvePath(body.to)}:`, sources);
369
+ }
370
+ return result;
371
+ }
372
+ case 'delete': {
373
+ const files = paths();
374
+ const result = await deleteEntries(files);
375
+ log('deleted', files);
376
+ return result;
377
+ }
378
+ case 'chmod':
379
+ return changeMode(resolvePath(body.path), body.mode);
380
+ default:
381
+ throw new HttpError(400, `Unknown operation ${JSON.stringify(body.op)}`);
382
+ }
383
+ }
384
+ function sendNothing(res) {
385
+ res.writeHead(204, { 'Cache-Control': 'no-store' }).end();
386
+ }
387
+ // attachment; filename="…": an ASCII name for old browsers, the name whole
388
+ // for the others (RFC 6266)
389
+ function contentDisposition(name) {
390
+ const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, '_');
391
+ const encoded = encodeURIComponent(name).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
392
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
393
+ }
394
+ // A file of any size, streamed as it is
395
+ function sendDownload(res, file, stat) {
396
+ res.writeHead(200, {
397
+ ...SANDBOXED,
398
+ 'Content-Type': CONTENT_TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream',
399
+ 'Content-Length': stat.size,
400
+ 'Content-Disposition': contentDisposition(path.basename(file)),
401
+ 'Cache-Control': 'no-store',
402
+ });
403
+ createReadStream(file)
404
+ .on('error', () => res.destroy())
405
+ .pipe(res);
406
+ }
407
+ // Entries of one folder, as tar archives them, gzipped, as it goes
408
+ async function sendArchive(res, files, logger) {
409
+ const folder = path.dirname(files[0]);
410
+ const name = files.length === 1 ? path.basename(files[0]) : `${path.basename(folder) || 'root'}-${files.length}-items`;
411
+ const tar = spawn('tar', archiveArguments(files), { stdio: ['ignore', 'pipe', 'pipe'] });
412
+ await new Promise((resolve, reject) => {
413
+ tar.once('spawn', resolve);
414
+ tar.once('error', (e) => reject(new HttpError(500, `Cannot archive: tar ${e.code ?? errorMessage(e)}`)));
415
+ });
416
+ let errors = '';
417
+ tar.stderr.on('data', (data) => (errors = (errors + data.toString()).slice(-2000)));
418
+ tar.on('close', (code) => {
419
+ if (code !== 0 && errors.trim()) {
420
+ logger.warn(`Download of ${files.length === 1 ? files[0] : folder}: ${errors.trim()}`);
421
+ }
422
+ });
423
+ // The page gave up: so does tar
424
+ res.on('close', () => tar.exitCode === null && tar.kill());
425
+ res.writeHead(200, {
426
+ ...SANDBOXED,
427
+ 'Content-Type': 'application/gzip',
428
+ 'Content-Disposition': contentDisposition(`${name}.tar.gz`),
429
+ 'Cache-Control': 'no-store',
430
+ });
431
+ tar.stdout.pipe(res);
189
432
  }
190
433
  const gzipAsync = promisify(gzip);
191
434
  // By file path: the compressed content of the file at that size and time
@@ -0,0 +1,3 @@
1
+ import type { Pixels } from './image_encoding.ts';
2
+ export declare const NETPBM_EXTENSIONS: Set<string>;
3
+ export declare function decodeNetpbm(data: Buffer): Pixels;
@@ -0,0 +1,69 @@
1
+ // Netpbm images, PGM and PPM, which browsers do not show: the maps of
2
+ // nav2_map_server and map_server are PGM. Decoded into pixels for sharp to
3
+ // make PNGs of, for the file manager's previews.
4
+ export const NETPBM_EXTENSIONS = new Set(['.pgm', '.ppm', '.pnm']);
5
+ // 10,000 × 10,000: a map of a km² at 10 cm
6
+ const MAX_PIXELS = 100_000_000;
7
+ // P2 and P5 gray, P3 and P6 colour; text (P2, P3) or binary (P5, P6), 8 or
8
+ // 16 bit (big endian), scaled to 8 bit
9
+ export function decodeNetpbm(data) {
10
+ const magic = data.subarray(0, 2).toString('latin1');
11
+ if (!['P2', 'P3', 'P5', 'P6'].includes(magic)) {
12
+ throw new Error(`Not a PGM nor PPM image (${JSON.stringify(magic)})`);
13
+ }
14
+ const channels = magic === 'P3' || magic === 'P6' ? 3 : 1;
15
+ const binary = magic === 'P5' || magic === 'P6';
16
+ // The header's numbers, after comments (# to the end of the line)
17
+ let offset = 2;
18
+ const number = () => {
19
+ for (;;) {
20
+ // Tab, line feed, vertical tab, form feed, carriage return, space
21
+ while (offset < data.length && (data[offset] === 0x20 || (data[offset] >= 0x09 && data[offset] <= 0x0d))) {
22
+ offset++;
23
+ }
24
+ if (data[offset] !== 0x23) {
25
+ break;
26
+ }
27
+ while (offset < data.length && data[offset] !== 0x0a) {
28
+ offset++;
29
+ }
30
+ }
31
+ const start = offset;
32
+ while (offset < data.length && data[offset] >= 0x30 && data[offset] <= 0x39) {
33
+ offset++;
34
+ }
35
+ if (offset === start) {
36
+ throw new Error('Malformed PGM or PPM header');
37
+ }
38
+ return Number(data.subarray(start, offset).toString('latin1'));
39
+ };
40
+ const width = number();
41
+ const height = number();
42
+ const max = number();
43
+ if (width < 1 || height < 1 || max < 1 || max > 65535) {
44
+ throw new Error(`Malformed PGM or PPM header: ${width}×${height}, max ${max}`);
45
+ }
46
+ if (width * height > MAX_PIXELS) {
47
+ throw new Error(`Too large to preview: ${width}×${height}`);
48
+ }
49
+ const count = width * height * channels;
50
+ const pixels = Buffer.alloc(count);
51
+ const scale = (value) => (max === 255 ? value : Math.round((Math.min(value, max) * 255) / max));
52
+ if (binary) {
53
+ // One whitespace after the header
54
+ offset++;
55
+ const bytes = max > 255 ? 2 : 1;
56
+ if (data.length - offset < count * bytes) {
57
+ throw new Error('Truncated PGM or PPM image');
58
+ }
59
+ for (let i = 0; i < count; i++) {
60
+ pixels[i] = scale(bytes === 2 ? data.readUInt16BE(offset + i * 2) : data[offset + i]);
61
+ }
62
+ }
63
+ else {
64
+ for (let i = 0; i < count; i++) {
65
+ pixels[i] = scale(number());
66
+ }
67
+ }
68
+ return { data: pixels, width, height, channels };
69
+ }
@@ -0,0 +1,41 @@
1
+ import { type NetworkRequest, type NetworkResult, type NetworkSecrets, type NetworkState } from '../shared/networking.ts';
2
+ import { type Logger } from './util.ts';
3
+ type NetworkListener = (state: NetworkState) => void;
4
+ export declare class Networking {
5
+ private readonly logger;
6
+ private readonly listeners;
7
+ private latest;
8
+ private latestJson;
9
+ private timer;
10
+ private reading;
11
+ private again;
12
+ private monitor;
13
+ private monitorTimer;
14
+ private debounce;
15
+ private settings;
16
+ private settingsKey;
17
+ private settingsAt;
18
+ private permissions;
19
+ private permissionsAt;
20
+ private activity;
21
+ private nextActivity;
22
+ private scannedAt;
23
+ private scanning;
24
+ private scanTimer;
25
+ constructor(logger: Logger);
26
+ watch(listener: NetworkListener): () => void;
27
+ act(request: NetworkRequest): Promise<NetworkResult | NetworkSecrets>;
28
+ stopAll(): void;
29
+ private connection;
30
+ private track;
31
+ private rescan;
32
+ private setScanning;
33
+ private secrets;
34
+ private publish;
35
+ private emit;
36
+ private refresh;
37
+ private read;
38
+ private startMonitor;
39
+ private stop;
40
+ }
41
+ export {};