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
@@ -0,0 +1,717 @@
1
+ // The robot's files, for the file manager widgets (see
2
+ // src/shared/file_manager.ts): folders listed with their entries' sizes,
3
+ // times, owners and permissions; text files read, and saved only over the
4
+ // version read; entries made, renamed, moved, copied, deleted and their
5
+ // permissions changed; uploads received; folders analyzed, what takes their
6
+ // space. As the server's user, wherever it may: but the root, the home folder
7
+ // and the system's top folders are never deleted nor moved.
8
+ import { randomBytes } from 'node:crypto';
9
+ import { promises as fs } from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { FS_MAX_ENTRIES, FS_MAX_TEXT_BYTES, } from '../shared/file_manager.js';
13
+ import { spaceOf } from './disks.js';
14
+ import { HttpError } from './panels.js';
15
+ import { contentVersion, errorMessage, packageShareDirectory, replaceFile } from './util.js';
16
+ // The users' and groups' names change rarely: read again after this
17
+ const NAMES_TTL_MS = 60_000;
18
+ // A folder analyzed is read for this long at most, this many entries at most
19
+ const ANALYSIS_MS = 15_000;
20
+ const ANALYSIS_ENTRIES = 1_000_000;
21
+ // Folders read at once, and entries of one stat'ed at once
22
+ const ANALYSIS_FOLDERS = 16;
23
+ const STAT_BATCH = 256;
24
+ // Of the analysis's lists
25
+ const ANALYSIS_TOP = 10;
26
+ const ANALYSIS_TYPES = 12;
27
+ const ANALYSIS_CHILDREN = 50;
28
+ // Of an upload's folders, a folder dropped
29
+ const MAX_UPLOAD_DEPTH = 32;
30
+ // A binary file has a NUL in its first bytes
31
+ const BINARY_PROBE = 8000;
32
+ // .tar.gz, of the names numbered as a whole
33
+ const COMPOUND_EXTENSION = /\.tar\.(gz|bz2|xz|zst)$/i;
34
+ // Natural order: file2 before file10, case aside
35
+ export const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
36
+ const REASONS = {
37
+ ENOENT: [404, 'no such file or folder'],
38
+ ENOTDIR: [400, 'not a folder'],
39
+ EISDIR: [400, 'it is a folder'],
40
+ EACCES: [403, 'permission denied'],
41
+ EPERM: [403, 'not permitted'],
42
+ EROFS: [403, 'read-only file system'],
43
+ EEXIST: [409, 'it exists already'],
44
+ ENOTEMPTY: [409, 'the folder is not empty'],
45
+ EBUSY: [409, 'in use'],
46
+ ETXTBSY: [409, 'in use'],
47
+ ENOSPC: [507, 'no space left on the disk'],
48
+ EDQUOT: [507, 'disk quota exceeded'],
49
+ ENAMETOOLONG: [400, 'name too long'],
50
+ ELOOP: [400, 'too many levels of links'],
51
+ EINVAL: [400, 'invalid'],
52
+ };
53
+ // A failure of the file system, for the page: what failed, and why in words
54
+ export function fsError(e, what) {
55
+ if (e instanceof HttpError) {
56
+ return e;
57
+ }
58
+ const code = e.code ?? '';
59
+ const [status, reason] = REASONS[code] ?? [500, errorMessage(e)];
60
+ return new HttpError(status, `${what}: ${reason}`);
61
+ }
62
+ // A robot path (/…, ~/…, package://<package>/…): absolute, normalized, links
63
+ // not resolved
64
+ export function resolvePath(input, home = os.homedir()) {
65
+ if (typeof input !== 'string' || !input.trim()) {
66
+ throw new HttpError(400, 'No path');
67
+ }
68
+ const trimmed = input.trim();
69
+ const packaged = /^package:\/\/([^/]+)(\/.*)?$/.exec(trimmed);
70
+ if (packaged) {
71
+ const share = packageShareDirectory(packaged[1]);
72
+ if (!share) {
73
+ throw new HttpError(404, `No package "${packaged[1]}"`);
74
+ }
75
+ return path.resolve(share, `.${packaged[2] ?? ''}`);
76
+ }
77
+ const expanded = trimmed === '~' || trimmed.startsWith('~/') ? path.join(home, trimmed.slice(1)) : trimmed;
78
+ if (!path.isAbsolute(expanded)) {
79
+ throw new HttpError(400, `Not an absolute path: "${input}"`);
80
+ }
81
+ return path.resolve(expanded);
82
+ }
83
+ // The name of an entry, as typed: not empty, nor . or .., no separator
84
+ export function checkName(name) {
85
+ if (typeof name !== 'string' || !name || name === '.' || name === '..' || /[/\0]/.test(name)) {
86
+ throw new HttpError(400, `Not a valid name: ${JSON.stringify(name)}`);
87
+ }
88
+ if (Buffer.byteLength(name) > 255) {
89
+ throw new HttpError(400, 'Name too long: 255 bytes at most');
90
+ }
91
+ return name;
92
+ }
93
+ // Never deleted nor moved, whatever the permissions: the root, the home
94
+ // folder, the system's top folders (/usr, /etc, /home)
95
+ function protectedPath(file, home) {
96
+ const root = path.parse(file).root;
97
+ if (file === root || path.dirname(file) === root) {
98
+ return 'a folder of the system';
99
+ }
100
+ return file === home ? 'the home folder' : null;
101
+ }
102
+ function under(file, folder) {
103
+ return file === folder || file.startsWith(folder.endsWith(path.sep) ? folder : folder + path.sep);
104
+ }
105
+ function exists(file) {
106
+ return fs.lstat(file).then(() => true, () => false);
107
+ }
108
+ // "map (2).yaml": numbered before its extension, "logs (2).tar.gz" before both
109
+ export function numberedName(name, n) {
110
+ const compound = COMPOUND_EXTENSION.exec(name);
111
+ const dot = compound ? compound.index : name.lastIndexOf('.');
112
+ const split = dot > 0 ? dot : name.length;
113
+ const stem = name.slice(0, split).replace(/ \(\d+\)$/, '');
114
+ return `${stem} (${n})${name.slice(split)}`;
115
+ }
116
+ // The first numbered name free in the folder
117
+ async function freeName(folder, name) {
118
+ for (let n = 2; n < 10_000; n++) {
119
+ const candidate = numberedName(name, n);
120
+ if (!(await exists(path.join(folder, candidate)))) {
121
+ return candidate;
122
+ }
123
+ }
124
+ throw new HttpError(409, `No free name for ${name}`);
125
+ }
126
+ let accounts = null;
127
+ async function accountNames() {
128
+ if (!accounts || Date.now() - accounts.at > NAMES_TTL_MS) {
129
+ const read = async (file) => {
130
+ const names = new Map();
131
+ for (const line of (await fs.readFile(file, 'utf8').catch(() => '')).split('\n')) {
132
+ const [name, , id] = line.split(':');
133
+ if (name && id && /^\d+$/.test(id)) {
134
+ names.set(Number(id), name);
135
+ }
136
+ }
137
+ return names;
138
+ };
139
+ accounts = { at: Date.now(), users: await read('/etc/passwd'), groups: await read('/etc/group') };
140
+ }
141
+ return accounts;
142
+ }
143
+ // What the server's user may do with it, by its permission bits (ACLs aside)
144
+ function access(stat) {
145
+ const uid = process.getuid?.() ?? -1;
146
+ if (uid === 0) {
147
+ return { readable: true, writable: true };
148
+ }
149
+ const groups = new Set([process.getgid?.(), ...(process.getgroups?.() ?? [])]);
150
+ const shift = stat.uid === uid ? 6 : groups.has(stat.gid) ? 3 : 0;
151
+ const bits = (stat.mode >> shift) & 7;
152
+ // Folders are gone into to read or change their entries
153
+ const enter = !stat.isDirectory() || (bits & 1) !== 0;
154
+ return { readable: (bits & 4) !== 0 && enter, writable: (bits & 2) !== 0 && enter };
155
+ }
156
+ async function readEntry(file, names) {
157
+ const own = await fs.lstat(file).catch(() => null);
158
+ if (!own) {
159
+ return null;
160
+ }
161
+ let stat = own;
162
+ let link = null;
163
+ let broken = false;
164
+ if (own.isSymbolicLink()) {
165
+ link = await fs.readlink(file).catch(() => '');
166
+ const target = await fs.stat(file).catch(() => null);
167
+ broken = !target;
168
+ stat = target ?? own;
169
+ }
170
+ const kind = broken ? 'other' : stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other';
171
+ return {
172
+ name: path.basename(file) || file,
173
+ kind,
174
+ link,
175
+ size: stat.size,
176
+ modified: Math.round(stat.mtimeMs),
177
+ mode: stat.mode & 0o7777,
178
+ owner: names.users.get(stat.uid) ?? String(stat.uid),
179
+ group: names.groups.get(stat.gid) ?? String(stat.gid),
180
+ ...access(stat),
181
+ bag: kind === 'dir' && (await exists(path.join(file, 'metadata.yaml'))),
182
+ };
183
+ }
184
+ function byKindAndName(a, b) {
185
+ return Number(b.kind === 'dir') - Number(a.kind === 'dir') || collator.compare(a.name, b.name);
186
+ }
187
+ // Of a folder: its entries (FS_MAX_ENTRIES at most), its permissions, the
188
+ // space left on its file system
189
+ export async function listFolder(folder) {
190
+ let found;
191
+ try {
192
+ found = await fs.readdir(folder, { withFileTypes: true });
193
+ }
194
+ catch (e) {
195
+ throw fsError(e, `Cannot list ${folder}`);
196
+ }
197
+ // Those kept, if not all: folders first (links to folders are told only
198
+ // once followed)
199
+ const kindOf = (dirent) => ({ name: dirent.name, kind: dirent.isDirectory() ? 'dir' : 'file' });
200
+ found.sort((a, b) => byKindAndName(kindOf(a), kindOf(b)));
201
+ const kept = found.slice(0, FS_MAX_ENTRIES);
202
+ const names = await accountNames();
203
+ const entries = (await Promise.all(kept.map((dirent) => readEntry(path.join(folder, dirent.name), names)))).filter((entry) => entry !== null);
204
+ entries.sort(byKindAndName);
205
+ const [stat, space] = await Promise.all([fs.stat(folder), spaceOf(folder)]);
206
+ const truncated = found.length - kept.length;
207
+ const { writable } = access(stat);
208
+ // The space to three digits, as shown: a disk being written changes it a little all the time
209
+ const version = contentVersion(JSON.stringify([entries, truncated, writable, space && Number(space.free.toPrecision(3))]));
210
+ const root = path.parse(folder).root;
211
+ return { path: folder, parent: folder === root ? null : path.dirname(folder), entries, truncated, writable, version, space };
212
+ }
213
+ export async function entryDetails(file) {
214
+ const entry = await readEntry(file, await accountNames());
215
+ if (!entry) {
216
+ throw new HttpError(404, `No such file or folder: ${file}`);
217
+ }
218
+ const stat = await fs.stat(file).catch(() => fs.lstat(file));
219
+ const real = await fs.realpath(file).catch(() => null);
220
+ let items = null;
221
+ let hidden = null;
222
+ if (entry.kind === 'dir') {
223
+ const names = await fs.readdir(file).catch(() => null);
224
+ if (names) {
225
+ items = names.length;
226
+ hidden = names.filter((name) => name.startsWith('.')).length;
227
+ }
228
+ }
229
+ return {
230
+ ...entry,
231
+ path: file,
232
+ realPath: real && real !== file ? real : null,
233
+ created: stat.birthtimeMs > 0 ? Math.round(stat.birthtimeMs) : null,
234
+ accessed: Math.round(stat.atimeMs),
235
+ changed: Math.round(stat.ctimeMs),
236
+ inode: stat.ino,
237
+ links: stat.nlink,
238
+ items,
239
+ hidden,
240
+ space: await spaceOf(entry.kind === 'dir' ? file : path.dirname(file)),
241
+ };
242
+ }
243
+ // Where the file manager offers to go: home, and the ROS installations and
244
+ // workspaces sourced, overlays first
245
+ export function listPlaces(home = os.homedir(), prefixes = process.env.AMENT_PREFIX_PATH ?? '') {
246
+ const ros = new Map();
247
+ for (const prefix of prefixes.split(':').filter(Boolean)) {
248
+ const distribution = /^\/opt\/ros\/([^/]+)/.exec(prefix);
249
+ if (distribution) {
250
+ ros.set(`/opt/ros/${distribution[1]}`, `ROS 2 ${distribution[1]}`);
251
+ continue;
252
+ }
253
+ // A workspace's install folder (merged), or one of its packages' (isolated)
254
+ const parts = prefix.split('/');
255
+ const install = parts.lastIndexOf('install');
256
+ const workspace = install > 0 ? parts.slice(0, install).join('/') || '/' : prefix;
257
+ if (!ros.has(workspace)) {
258
+ ros.set(workspace, path.basename(workspace) || workspace);
259
+ }
260
+ }
261
+ let user = '';
262
+ try {
263
+ user = os.userInfo().username;
264
+ }
265
+ catch {
266
+ // A user without a name: none shown
267
+ }
268
+ return { home, user, ros: [...ros].map(([folder, name]) => ({ name, path: folder })) };
269
+ }
270
+ export async function makeFolder(folder) {
271
+ checkName(path.basename(folder));
272
+ try {
273
+ await fs.mkdir(folder);
274
+ }
275
+ catch (e) {
276
+ throw fsError(e, `Cannot make the folder ${path.basename(folder)}`);
277
+ }
278
+ return { paths: [folder], conflicts: [] };
279
+ }
280
+ export async function makeFile(file) {
281
+ checkName(path.basename(file));
282
+ try {
283
+ await fs.writeFile(file, '', { flag: 'wx' });
284
+ }
285
+ catch (e) {
286
+ throw fsError(e, `Cannot make the file ${path.basename(file)}`);
287
+ }
288
+ return { paths: [file], conflicts: [] };
289
+ }
290
+ export async function renameEntry(file, name, home = os.homedir()) {
291
+ checkName(name);
292
+ const reason = protectedPath(file, home);
293
+ if (reason) {
294
+ throw new HttpError(403, `Not renamed: ${file} is ${reason}`);
295
+ }
296
+ const target = path.join(path.dirname(file), name);
297
+ if (target !== file) {
298
+ if (await exists(target)) {
299
+ throw new HttpError(409, `Not renamed: ${name} exists already`);
300
+ }
301
+ try {
302
+ await fs.rename(file, target);
303
+ }
304
+ catch (e) {
305
+ throw fsError(e, `Cannot rename ${path.basename(file)}`);
306
+ }
307
+ }
308
+ return { paths: [target], conflicts: [] };
309
+ }
310
+ async function moveEntry(source, target) {
311
+ try {
312
+ await fs.rename(source, target);
313
+ }
314
+ catch (e) {
315
+ // To another file system: copied, then deleted
316
+ if (e.code !== 'EXDEV') {
317
+ throw e;
318
+ }
319
+ await copyEntry(source, target);
320
+ await fs.rm(source, { recursive: true });
321
+ }
322
+ }
323
+ function copyEntry(source, target) {
324
+ return fs.cp(source, target, {
325
+ recursive: true,
326
+ errorOnExist: true,
327
+ force: false,
328
+ preserveTimestamps: true,
329
+ verbatimSymlinks: true,
330
+ });
331
+ }
332
+ // Moves or copies entries into a folder. Into their own folder, a move does
333
+ // nothing and a copy duplicates them. With conflict 'ask', nothing is done
334
+ // while any name is taken: the result says which.
335
+ export async function transfer(op, sources, to, conflict, home = os.homedir()) {
336
+ const folder = await fs.stat(to).catch((e) => {
337
+ throw fsError(e, `Cannot ${op} into ${to}`);
338
+ });
339
+ if (!folder.isDirectory()) {
340
+ throw new HttpError(400, `Cannot ${op} into ${to}: not a folder`);
341
+ }
342
+ const realTo = await fs.realpath(to);
343
+ const plan = [];
344
+ const conflicts = [];
345
+ const names = new Set();
346
+ for (const source of sources) {
347
+ const name = path.basename(source);
348
+ if (names.has(name)) {
349
+ throw new HttpError(400, `Two of them are named ${name}`);
350
+ }
351
+ names.add(name);
352
+ const reason = op === 'move' ? protectedPath(source, home) : null;
353
+ if (reason) {
354
+ throw new HttpError(403, `Not moved: ${source} is ${reason}`);
355
+ }
356
+ const stat = await fs.lstat(source).catch((e) => {
357
+ throw fsError(e, `Cannot ${op} ${name}`);
358
+ });
359
+ // The entry itself, its folder's links resolved
360
+ const real = path.join(await fs.realpath(path.dirname(source)), name);
361
+ if (stat.isDirectory() && under(realTo, real)) {
362
+ throw new HttpError(400, `Cannot ${op} ${name} into itself`);
363
+ }
364
+ if (path.dirname(real) === realTo) {
365
+ if (op === 'copy') {
366
+ plan.push({ source, target: path.join(to, await freeName(to, name)), replace: false });
367
+ }
368
+ continue;
369
+ }
370
+ const target = path.join(to, name);
371
+ if (!(await exists(target))) {
372
+ plan.push({ source, target, replace: false });
373
+ }
374
+ else if (conflict === 'ask') {
375
+ conflicts.push(name);
376
+ }
377
+ else if (conflict === 'keep-both') {
378
+ plan.push({ source, target: path.join(to, await freeName(to, name)), replace: false });
379
+ }
380
+ else if (conflict === 'replace') {
381
+ plan.push({ source, target, replace: true });
382
+ }
383
+ }
384
+ if (conflicts.length > 0) {
385
+ return { paths: [], conflicts };
386
+ }
387
+ const done = [];
388
+ for (const { source, target, replace } of plan) {
389
+ try {
390
+ if (replace) {
391
+ const reason = protectedPath(target, home);
392
+ if (reason) {
393
+ throw new HttpError(403, `${target} is ${reason}`);
394
+ }
395
+ await fs.rm(target, { recursive: true });
396
+ }
397
+ await (op === 'move' ? moveEntry(source, target) : copyEntry(source, target));
398
+ }
399
+ catch (e) {
400
+ const so = done.length > 0 ? ` (${done.length} done before)` : '';
401
+ throw fsError(e, `Cannot ${op} ${path.basename(source)}${so}`);
402
+ }
403
+ done.push(target);
404
+ }
405
+ return { paths: done, conflicts: [] };
406
+ }
407
+ export async function deleteEntries(files, home = os.homedir()) {
408
+ for (const file of files) {
409
+ const reason = protectedPath(file, home);
410
+ if (reason) {
411
+ throw new HttpError(403, `Not deleted: ${file} is ${reason}`);
412
+ }
413
+ }
414
+ for (const file of files) {
415
+ try {
416
+ await fs.rm(file, { recursive: true });
417
+ }
418
+ catch (e) {
419
+ // Deleted meanwhile, by another page
420
+ if (e.code !== 'ENOENT') {
421
+ throw fsError(e, `Cannot delete ${path.basename(file)}`);
422
+ }
423
+ }
424
+ }
425
+ return { paths: [], conflicts: [] };
426
+ }
427
+ export async function changeMode(file, mode) {
428
+ if (typeof mode !== 'number' || !Number.isInteger(mode) || mode < 0 || mode > 0o7777) {
429
+ throw new HttpError(400, 'A mode is an integer, 0 to 0o7777');
430
+ }
431
+ try {
432
+ await fs.chmod(file, mode);
433
+ }
434
+ catch (e) {
435
+ throw fsError(e, `Cannot change the permissions of ${path.basename(file)}`);
436
+ }
437
+ return { paths: [file], conflicts: [] };
438
+ }
439
+ // Size, time and inode: what tells a file changed without reading it
440
+ function stampOf(stat) {
441
+ return `${stat.size}-${stat.mtimeMs}-${stat.ino}`;
442
+ }
443
+ function decodeText(data, file) {
444
+ if (data.subarray(0, BINARY_PROBE).includes(0)) {
445
+ throw new HttpError(415, `${path.basename(file)} is not a text file`);
446
+ }
447
+ try {
448
+ // A byte order mark kept, to be saved again
449
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data);
450
+ }
451
+ catch {
452
+ throw new HttpError(415, `${path.basename(file)} is not UTF-8 text`);
453
+ }
454
+ }
455
+ // A text file, to edit; null if its stamp is still known
456
+ export async function readText(file, known = null) {
457
+ const stat = await fs.stat(file).catch((e) => {
458
+ throw fsError(e, `Cannot read ${path.basename(file)}`);
459
+ });
460
+ if (stat.isDirectory()) {
461
+ throw new HttpError(400, `${path.basename(file)} is a folder`);
462
+ }
463
+ if (!stat.isFile()) {
464
+ throw new HttpError(415, `${path.basename(file)} is not a regular file`);
465
+ }
466
+ const stamp = stampOf(stat);
467
+ if (stamp === known) {
468
+ return null;
469
+ }
470
+ if (stat.size > FS_MAX_TEXT_BYTES) {
471
+ throw new HttpError(413, `${path.basename(file)} is larger than ${FS_MAX_TEXT_BYTES / 1024 / 1024} MB: download it`);
472
+ }
473
+ const data = await fs.readFile(file).catch((e) => {
474
+ throw fsError(e, `Cannot read ${path.basename(file)}`);
475
+ });
476
+ const text = decodeText(data, file);
477
+ const real = await fs.realpath(file).catch(() => file);
478
+ const readOnly = await fs.access(real, fs.constants.W_OK).then(() => null, (e) => e.code === 'EROFS' ? 'On a read-only file system' : 'The server’s user cannot write it');
479
+ return { path: file, text, version: contentVersion(text), stamp, readOnly };
480
+ }
481
+ // Saves a text file, unless it changed since base (null: whatever it is), or
482
+ // as a new one (create)
483
+ export async function saveText(file, text, base, create) {
484
+ if (Buffer.byteLength(text) > FS_MAX_TEXT_BYTES) {
485
+ throw new HttpError(413, `Larger than ${FS_MAX_TEXT_BYTES / 1024 / 1024} MB`);
486
+ }
487
+ const name = path.basename(file);
488
+ if (create) {
489
+ checkName(name);
490
+ try {
491
+ await fs.writeFile(file, text, { flag: 'wx' });
492
+ }
493
+ catch (e) {
494
+ throw fsError(e, `Cannot save ${name}`);
495
+ }
496
+ }
497
+ else {
498
+ const real = await fs.realpath(file).catch((e) => {
499
+ throw fsError(e, `Cannot save ${name}`);
500
+ });
501
+ if (base !== null) {
502
+ const current = await fs.readFile(real).catch((e) => {
503
+ throw fsError(e, `Cannot save ${name}`);
504
+ });
505
+ const version = contentVersion(decodeText(current, file));
506
+ if (version !== base) {
507
+ return { version, stamp: stampOf(await fs.stat(real)), conflict: true };
508
+ }
509
+ }
510
+ try {
511
+ await replaceFile(real, text);
512
+ }
513
+ catch (e) {
514
+ throw fsError(e, `Cannot save ${name}`);
515
+ }
516
+ }
517
+ return { version: contentVersion(text), stamp: stampOf(await fs.stat(file)), conflict: false };
518
+ }
519
+ // Where an upload goes: its name in the folder, with the folders it is in
520
+ // (those of a folder dropped), which are made. null: skipped, it exists.
521
+ export async function uploadTarget(folder, name, conflict) {
522
+ const parts = name.split('/');
523
+ if (parts.length > MAX_UPLOAD_DEPTH) {
524
+ throw new HttpError(400, `More than ${MAX_UPLOAD_DEPTH} folders deep: ${name}`);
525
+ }
526
+ parts.forEach(checkName);
527
+ const parent = path.join(folder, ...parts.slice(0, -1));
528
+ try {
529
+ await fs.mkdir(parent, { recursive: true });
530
+ }
531
+ catch (e) {
532
+ throw fsError(e, `Cannot upload ${name}`);
533
+ }
534
+ const last = parts.at(-1);
535
+ const target = path.join(parent, last);
536
+ if (!(await exists(target))) {
537
+ return target;
538
+ }
539
+ switch (conflict) {
540
+ case 'ask':
541
+ throw new HttpError(409, `${last} exists already`);
542
+ case 'skip':
543
+ return null;
544
+ case 'keep-both':
545
+ return path.join(parent, await freeName(parent, last));
546
+ case 'replace':
547
+ return target;
548
+ }
549
+ }
550
+ // Writes an upload: into a hidden file, renamed to the target once complete
551
+ export async function receiveFile(body, target, maxBytes) {
552
+ const temporary = path.join(path.dirname(target), `.${path.basename(target)}.${randomBytes(4).toString('hex')}.upload`);
553
+ let size = 0;
554
+ try {
555
+ const handle = await fs.open(temporary, 'wx');
556
+ try {
557
+ for await (const chunk of body) {
558
+ size += chunk.length;
559
+ if (size > maxBytes) {
560
+ throw new HttpError(413, `Larger than ${Math.round(maxBytes / 1024 ** 3)} GB`);
561
+ }
562
+ await handle.write(chunk);
563
+ }
564
+ }
565
+ finally {
566
+ await handle.close();
567
+ }
568
+ await fs.rename(temporary, target);
569
+ }
570
+ catch (e) {
571
+ await fs.rm(temporary, { force: true });
572
+ throw fsError(e, `Cannot upload ${path.basename(target)}`);
573
+ }
574
+ return size;
575
+ }
576
+ // The arguments of tar archiving, gzipped, entries of one folder
577
+ export function archiveArguments(files) {
578
+ const folder = path.dirname(files[0]);
579
+ if (files.some((file) => path.dirname(file) !== folder || file === path.parse(file).root)) {
580
+ throw new HttpError(400, 'Entries of one folder only are downloaded together');
581
+ }
582
+ // ./: names starting with - are not options
583
+ return ['-czf', '-', '-C', folder, ...files.map((file) => `./${path.basename(file)}`)];
584
+ }
585
+ // Of the list, by key, the largest TOP kept
586
+ function keepTop(list, item, key, top = ANALYSIS_TOP) {
587
+ if (list.length >= top && key(item) <= key(list[list.length - 1])) {
588
+ return;
589
+ }
590
+ let index = list.length;
591
+ while (index > 0 && key(list[index - 1]) < key(item)) {
592
+ index--;
593
+ }
594
+ list.splice(index, 0, item);
595
+ list.length = Math.min(list.length, top);
596
+ }
597
+ // Everything under a folder, on its file system only, read for ANALYSIS_MS
598
+ // at most: sizes, counts, the largest and latest files, by extension, by entry
599
+ export async function analyzeFolder(root, limits = { ms: ANALYSIS_MS, entries: ANALYSIS_ENTRIES }) {
600
+ const started = Date.now();
601
+ const rootStat = await fs.stat(root).catch((e) => {
602
+ throw fsError(e, `Cannot analyze ${root}`);
603
+ });
604
+ if (!rootStat.isDirectory()) {
605
+ throw new HttpError(400, `${root} is not a folder`);
606
+ }
607
+ await fs.access(root, fs.constants.R_OK | fs.constants.X_OK).catch((e) => {
608
+ throw fsError(e, `Cannot analyze ${root}`);
609
+ });
610
+ const result = {
611
+ path: root,
612
+ size: 0,
613
+ disk: 0,
614
+ files: 0,
615
+ folders: 0,
616
+ links: 0,
617
+ children: [],
618
+ types: [],
619
+ largest: [],
620
+ recent: [],
621
+ partial: null,
622
+ unreadable: 0,
623
+ mounts: 0,
624
+ elapsedMs: 0,
625
+ };
626
+ const children = new Map();
627
+ const types = new Map();
628
+ let entries = 0;
629
+ // Folders to read, and the root's entry they are in ('' the root)
630
+ const queue = [{ folder: root, top: '' }];
631
+ const visit = async (folder, top) => {
632
+ let names;
633
+ try {
634
+ names = await fs.readdir(folder);
635
+ }
636
+ catch {
637
+ result.unreadable++;
638
+ return;
639
+ }
640
+ for (let start = 0; start < names.length; start += STAT_BATCH) {
641
+ const batch = names.slice(start, start + STAT_BATCH);
642
+ const stats = await Promise.all(batch.map((name) => fs.lstat(path.join(folder, name)).catch(() => null)));
643
+ batch.forEach((name, index) => {
644
+ const stat = stats[index];
645
+ if (!stat) {
646
+ return;
647
+ }
648
+ entries++;
649
+ const file = path.join(folder, name);
650
+ if (!top) {
651
+ children.set(name, { name, kind: stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other', size: 0, files: 0 });
652
+ }
653
+ const child = children.get(top || name);
654
+ if (stat.isDirectory()) {
655
+ if (stat.dev !== rootStat.dev) {
656
+ result.mounts++;
657
+ return;
658
+ }
659
+ result.folders++;
660
+ result.disk += stat.blocks * 512;
661
+ queue.push({ folder: file, top: top || name });
662
+ }
663
+ else if (stat.isSymbolicLink()) {
664
+ result.links++;
665
+ }
666
+ else if (stat.isFile()) {
667
+ result.files++;
668
+ result.size += stat.size;
669
+ result.disk += stat.blocks * 512;
670
+ child.size += stat.size;
671
+ child.files++;
672
+ const type = path.extname(name).toLowerCase();
673
+ const sum = types.get(type) ?? { files: 0, size: 0 };
674
+ sum.files++;
675
+ sum.size += stat.size;
676
+ types.set(type, sum);
677
+ const relative = path.relative(root, file);
678
+ keepTop(result.largest, { path: relative, size: stat.size }, (item) => item.size);
679
+ keepTop(result.recent, { path: relative, modified: Math.round(stat.mtimeMs) }, (item) => item.modified);
680
+ }
681
+ });
682
+ }
683
+ };
684
+ await new Promise((resolve) => {
685
+ let active = 0;
686
+ const pump = () => {
687
+ if (queue.length > 0 && !result.partial) {
688
+ if (entries >= limits.entries) {
689
+ result.partial = `Stopped after ${limits.entries.toLocaleString('en')} entries`;
690
+ }
691
+ else if (Date.now() - started > limits.ms) {
692
+ result.partial = `Stopped after ${Math.round(limits.ms / 1000)} s`;
693
+ }
694
+ }
695
+ while (!result.partial && active < ANALYSIS_FOLDERS && queue.length > 0) {
696
+ // The latest first: deep before wide, few folders waiting
697
+ const { folder, top } = queue.pop();
698
+ active++;
699
+ void visit(folder, top).finally(() => {
700
+ active--;
701
+ pump();
702
+ });
703
+ }
704
+ if (active === 0 && (queue.length === 0 || result.partial)) {
705
+ resolve();
706
+ }
707
+ };
708
+ pump();
709
+ });
710
+ result.children = [...children.values()].sort((a, b) => b.size - a.size || collator.compare(a.name, b.name)).slice(0, ANALYSIS_CHILDREN);
711
+ result.types = [...types]
712
+ .map(([type, sum]) => ({ type, ...sum }))
713
+ .sort((a, b) => b.size - a.size)
714
+ .slice(0, ANALYSIS_TYPES);
715
+ result.elapsedMs = Date.now() - started;
716
+ return result;
717
+ }