herdr-remote 0.2.8 → 0.2.10
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/dist/tui.mjs +5 -2
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/src/config.js +4 -1
- package/src/host-connector.js +103 -7
- package/src/pasted-files.js +176 -0
package/dist/tui.mjs
CHANGED
|
@@ -76,7 +76,10 @@ var require_config = __commonJS({
|
|
|
76
76
|
publicUrl: "",
|
|
77
77
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
78
78
|
remoteUrl: "",
|
|
79
|
-
|
|
79
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
80
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
81
|
+
// hosted relay accepts.
|
|
82
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
80
83
|
maxClientsPerHost: 16,
|
|
81
84
|
maxHosts: 1024,
|
|
82
85
|
maxPendingHandshakes: 1024,
|
|
@@ -4012,7 +4015,7 @@ function About({ ctx }) {
|
|
|
4012
4015
|
children: updateLabel
|
|
4013
4016
|
}
|
|
4014
4017
|
) }),
|
|
4015
|
-
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.
|
|
4018
|
+
/* @__PURE__ */ jsx10(Row, { label: t("about.version"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: "0.2.10" }) }),
|
|
4016
4019
|
/* @__PURE__ */ jsx10(Row, { label: t("about.configPath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.configPath)() }) }),
|
|
4017
4020
|
/* @__PURE__ */ jsx10(Row, { label: t("about.statePath"), children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: (0, import_config.stateDir)() }) }),
|
|
4018
4021
|
/* @__PURE__ */ jsx10(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text9, { color: theme.muted, children: t("about.docs") }) }),
|
package/herdr-plugin.toml
CHANGED
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -62,7 +62,10 @@ const DEFAULTS = {
|
|
|
62
62
|
publicUrl: '',
|
|
63
63
|
// Operator-run relay, e.g. wss://herdr.example.com (mode "remote" only).
|
|
64
64
|
remoteUrl: '',
|
|
65
|
-
|
|
65
|
+
// Kept in step with the relay package's own default: the CLI starts a
|
|
66
|
+
// relay of its own, and a smaller ceiling here would reject an upload the
|
|
67
|
+
// hosted relay accepts.
|
|
68
|
+
maxPayloadBytes: 5 * 1024 * 1024,
|
|
66
69
|
maxClientsPerHost: 16,
|
|
67
70
|
maxHosts: 1024,
|
|
68
71
|
maxPendingHandshakes: 1024,
|
package/src/host-connector.js
CHANGED
|
@@ -12,9 +12,16 @@ const { PtySession } = require('./pty-session');
|
|
|
12
12
|
const { resolveHerdrCommand, verifyHerdrCommand, herdrNotFoundMessage } = require('./herdr-command');
|
|
13
13
|
// The wire format lives in the relay package so both ends of the protocol are
|
|
14
14
|
// generated from one definition.
|
|
15
|
-
const {
|
|
15
|
+
const {
|
|
16
|
+
packStreamFrame,
|
|
17
|
+
unpackStreamFrame,
|
|
18
|
+
packStreamFrameV2,
|
|
19
|
+
FRAME_TYPE_OUTPUT,
|
|
20
|
+
PROTOCOL_VERSION,
|
|
21
|
+
} = require('herdr-remote-relay/protocol');
|
|
16
22
|
const { resolveHostPalette } = require('./terminal-palette');
|
|
17
23
|
const { EXIT_REPLACED, EXIT_AUTH_FAILED } = require('./exit-codes');
|
|
24
|
+
const { savePastedFile, cleanPastedDir } = require('./pasted-files');
|
|
18
25
|
|
|
19
26
|
function randomId(prefix) {
|
|
20
27
|
return `${prefix}-${crypto.randomBytes(9).toString('base64url')}`;
|
|
@@ -76,6 +83,8 @@ class HostConnector {
|
|
|
76
83
|
: resolveHostPalette();
|
|
77
84
|
this.ws = null;
|
|
78
85
|
this.sessions = new Map();
|
|
86
|
+
this.streamIndexToId = new Map();
|
|
87
|
+
this.PtySession = options.PtySession || PtySession;
|
|
79
88
|
this.fastFailures = 0;
|
|
80
89
|
this.reconnectTimer = null;
|
|
81
90
|
this.heartbeatTimer = null;
|
|
@@ -89,6 +98,7 @@ class HostConnector {
|
|
|
89
98
|
|| process.env.HERDR_REMOTE_HOST_LOCK
|
|
90
99
|
|| path.join(stateDir(), 'host-connector.lock');
|
|
91
100
|
this.lockFd = null;
|
|
101
|
+
try { cleanPastedDir(); } catch {}
|
|
92
102
|
}
|
|
93
103
|
|
|
94
104
|
acquireLock() {
|
|
@@ -168,6 +178,8 @@ class HostConnector {
|
|
|
168
178
|
this.ws = ws;
|
|
169
179
|
ws.isAlive = true;
|
|
170
180
|
ws.on('open', () => {
|
|
181
|
+
// Disable Nagle's algorithm to prevent small frames from stalling ~40ms when delayed ACK is active.
|
|
182
|
+
try { ws._socket?.setNoDelay(true); } catch {}
|
|
171
183
|
ws.isAlive = true;
|
|
172
184
|
this.ready = false;
|
|
173
185
|
this.authFailure = false;
|
|
@@ -181,7 +193,7 @@ class HostConnector {
|
|
|
181
193
|
platform: process.platform,
|
|
182
194
|
arch: process.arch,
|
|
183
195
|
terminalPalette: this.terminalPalette || null,
|
|
184
|
-
capabilities: ['host_handoff', 'idle_heartbeat'],
|
|
196
|
+
capabilities: ['host_handoff', 'idle_heartbeat', 'binary_frame_v2'],
|
|
185
197
|
});
|
|
186
198
|
// The relay sends host_ready with the current browser count. No business
|
|
187
199
|
// heartbeat is started until that message says somebody is watching.
|
|
@@ -244,7 +256,10 @@ class HostConnector {
|
|
|
244
256
|
process.stderr.write(`herdr-remote host connector: invalid relay frame: ${error.message}\n`);
|
|
245
257
|
return;
|
|
246
258
|
}
|
|
247
|
-
if (frame.type === 'input')
|
|
259
|
+
if (frame.type === 'input') {
|
|
260
|
+
const streamId = frame.version === 2 ? this.streamIndexToId.get(frame.streamIndex) : frame.streamId;
|
|
261
|
+
if (streamId) this.sessions.get(streamId)?.pty.write(frame.payload);
|
|
262
|
+
}
|
|
248
263
|
return;
|
|
249
264
|
}
|
|
250
265
|
let message;
|
|
@@ -280,6 +295,7 @@ class HostConnector {
|
|
|
280
295
|
if (message.type === 'session_start') this.startSession(message);
|
|
281
296
|
else if (message.type === 'session_stop') this.stopSession(message.clientId || message.streamId);
|
|
282
297
|
else if (message.type === 'resize') this.resizeSession(message);
|
|
298
|
+
else if (message.type === 'paste_file') this.handlePasteFile(message);
|
|
283
299
|
}
|
|
284
300
|
|
|
285
301
|
/**
|
|
@@ -309,6 +325,7 @@ class HostConnector {
|
|
|
309
325
|
const streamId = typeof message.streamId === 'string' ? message.streamId : message.clientId;
|
|
310
326
|
if (!streamId) return;
|
|
311
327
|
this.stopSession(streamId);
|
|
328
|
+
const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
|
|
312
329
|
const missingHerdr = this.ensureHerdrCommand();
|
|
313
330
|
if (missingHerdr) {
|
|
314
331
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -320,7 +337,7 @@ class HostConnector {
|
|
|
320
337
|
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'herdr_socket_unavailable', message: socketInfo.reason });
|
|
321
338
|
return;
|
|
322
339
|
}
|
|
323
|
-
const pty = new PtySession({
|
|
340
|
+
const pty = new this.PtySession({
|
|
324
341
|
command: this.herdrCommand,
|
|
325
342
|
args: this.herdrArgs,
|
|
326
343
|
cwd: this.cwd,
|
|
@@ -328,34 +345,75 @@ class HostConnector {
|
|
|
328
345
|
});
|
|
329
346
|
const session = {
|
|
330
347
|
id: streamId,
|
|
348
|
+
streamIndex,
|
|
331
349
|
pty,
|
|
332
350
|
cols: Number(message.cols) || PtySession.DEFAULT_COLS,
|
|
333
351
|
rows: Number(message.rows) || PtySession.DEFAULT_ROWS,
|
|
334
352
|
createdAt: new Date().toISOString(),
|
|
335
353
|
startedAtMs: Date.now(),
|
|
336
354
|
clientId: streamId,
|
|
355
|
+
pendingOutput: [],
|
|
356
|
+
flushImmediate: null,
|
|
357
|
+
};
|
|
358
|
+
const flushOutput = () => {
|
|
359
|
+
if (session.flushImmediate) {
|
|
360
|
+
clearImmediate(session.flushImmediate);
|
|
361
|
+
session.flushImmediate = null;
|
|
362
|
+
}
|
|
363
|
+
if (session.pendingOutput.length === 0) return;
|
|
364
|
+
const chunks = session.pendingOutput;
|
|
365
|
+
session.pendingOutput = [];
|
|
366
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
367
|
+
const payload = Buffer.concat(chunks);
|
|
368
|
+
const frame = typeof session.streamIndex === 'number'
|
|
369
|
+
? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
|
|
370
|
+
: packStreamFrame('output', streamId, payload);
|
|
371
|
+
this.ws.send(frame);
|
|
372
|
+
}
|
|
337
373
|
};
|
|
338
374
|
try {
|
|
339
375
|
pty.start({
|
|
340
376
|
cols: session.cols,
|
|
341
377
|
rows: session.rows,
|
|
342
378
|
onData: (data) => {
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
379
|
+
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
380
|
+
if (chunk.length === 0) return;
|
|
381
|
+
session.pendingOutput.push(chunk);
|
|
382
|
+
if (!session.flushImmediate) {
|
|
383
|
+
// Coalesce burst output from the same tick into a single frame to reduce
|
|
384
|
+
// packet overhead without introducing timer latency.
|
|
385
|
+
session.flushImmediate = setImmediate(flushOutput);
|
|
386
|
+
}
|
|
345
387
|
},
|
|
346
388
|
onExit: ({ exitCode }) => {
|
|
347
389
|
if (this.sessions.get(streamId) !== session) return;
|
|
348
390
|
this.sessions.delete(streamId);
|
|
391
|
+
if (typeof session.streamIndex === 'number') {
|
|
392
|
+
this.streamIndexToId.delete(session.streamIndex);
|
|
393
|
+
}
|
|
394
|
+
if (session.flushImmediate) {
|
|
395
|
+
clearImmediate(session.flushImmediate);
|
|
396
|
+
session.flushImmediate = null;
|
|
397
|
+
}
|
|
398
|
+
session.pendingOutput = [];
|
|
349
399
|
this.reportSessionExit(session, exitCode);
|
|
350
400
|
this.sendHeartbeat();
|
|
351
401
|
},
|
|
352
402
|
});
|
|
353
403
|
} catch (error) {
|
|
404
|
+
if (session.flushImmediate) {
|
|
405
|
+
clearImmediate(session.flushImmediate);
|
|
406
|
+
session.flushImmediate = null;
|
|
407
|
+
}
|
|
408
|
+
session.pendingOutput = [];
|
|
354
409
|
sendJson(this.ws, { type: 'error', clientId: streamId, code: 'pty_start_failed', message: error.message });
|
|
355
410
|
pty.kill();
|
|
356
411
|
return;
|
|
357
412
|
}
|
|
358
413
|
this.sessions.set(streamId, session);
|
|
414
|
+
if (typeof streamIndex === 'number') {
|
|
415
|
+
this.streamIndexToId.set(streamIndex, streamId);
|
|
416
|
+
}
|
|
359
417
|
sendJson(this.ws, { type: 'session_ready', clientId: streamId });
|
|
360
418
|
this.sendHeartbeat();
|
|
361
419
|
}
|
|
@@ -390,6 +448,14 @@ class HostConnector {
|
|
|
390
448
|
const session = this.sessions.get(streamId);
|
|
391
449
|
if (!session) return;
|
|
392
450
|
this.sessions.delete(streamId);
|
|
451
|
+
if (typeof session.streamIndex === 'number') {
|
|
452
|
+
this.streamIndexToId.delete(session.streamIndex);
|
|
453
|
+
}
|
|
454
|
+
if (session.flushImmediate) {
|
|
455
|
+
clearImmediate(session.flushImmediate);
|
|
456
|
+
session.flushImmediate = null;
|
|
457
|
+
}
|
|
458
|
+
session.pendingOutput = [];
|
|
393
459
|
session.pty.kill();
|
|
394
460
|
this.sendHeartbeat();
|
|
395
461
|
}
|
|
@@ -419,9 +485,39 @@ class HostConnector {
|
|
|
419
485
|
session.pty.resize(session.cols, session.rows);
|
|
420
486
|
}
|
|
421
487
|
|
|
488
|
+
handlePasteFile(message) {
|
|
489
|
+
const streamId = message.clientId || message.streamId;
|
|
490
|
+
try {
|
|
491
|
+
const savedPath = savePastedFile({
|
|
492
|
+
mime: message.mime,
|
|
493
|
+
dataBase64: message.dataBase64,
|
|
494
|
+
});
|
|
495
|
+
sendJson(this.ws, {
|
|
496
|
+
type: 'paste_file_ready',
|
|
497
|
+
clientId: streamId,
|
|
498
|
+
path: savedPath,
|
|
499
|
+
});
|
|
500
|
+
} catch (error) {
|
|
501
|
+
sendJson(this.ws, {
|
|
502
|
+
type: 'error',
|
|
503
|
+
clientId: streamId,
|
|
504
|
+
code: 'paste_file_write_failed',
|
|
505
|
+
message: error.message || 'Failed to save pasted file',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
422
510
|
destroySessions() {
|
|
423
|
-
for (const session of this.sessions.values())
|
|
511
|
+
for (const session of this.sessions.values()) {
|
|
512
|
+
if (session.flushImmediate) {
|
|
513
|
+
clearImmediate(session.flushImmediate);
|
|
514
|
+
session.flushImmediate = null;
|
|
515
|
+
}
|
|
516
|
+
session.pendingOutput = [];
|
|
517
|
+
session.pty.kill();
|
|
518
|
+
}
|
|
424
519
|
this.sessions.clear();
|
|
520
|
+
this.streamIndexToId.clear();
|
|
425
521
|
}
|
|
426
522
|
|
|
427
523
|
sendHeartbeat(force = false) {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Secure file storage and validation for pasted clipboard images.
|
|
5
|
+
*
|
|
6
|
+
* Security requirements:
|
|
7
|
+
* 1. Pinned directory: writes strictly to path.join(stateDir(), 'pasted') (directory mode 0o700).
|
|
8
|
+
* 2. Unpredictable filename: generated via crypto.randomUUID(), never using client-provided strings.
|
|
9
|
+
* 3. Whitelisted extension: derived exclusively from verified MIME type.
|
|
10
|
+
* 4. Host-side size check: re-validated to enforce <= 3 MB independently of relay checks.
|
|
11
|
+
* 5. Magic bytes sniffing: inspects raw binary headers (PNG, JPEG, GIF, WebP) to prevent file-type spoofing.
|
|
12
|
+
* 6. File mode: files are created with mode 0o600.
|
|
13
|
+
* 7. Automatic pruning: caps at 20 files and 50 MB (oldest deleted first), removes >24h stale files at startup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const crypto = require('node:crypto');
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { stateDir } = require('./config');
|
|
20
|
+
const { ensureDir } = require('./state');
|
|
21
|
+
|
|
22
|
+
const MAX_PASTE_BYTES = 3 * 1024 * 1024; // 3 MB raw payload
|
|
23
|
+
const MAX_SAVED_FILES = 20;
|
|
24
|
+
const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
25
|
+
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
26
|
+
|
|
27
|
+
const MIME_CONFIG = {
|
|
28
|
+
'image/png': {
|
|
29
|
+
ext: '.png',
|
|
30
|
+
check: (buf) =>
|
|
31
|
+
buf.length >= 8 &&
|
|
32
|
+
buf[0] === 0x89 &&
|
|
33
|
+
buf[1] === 0x50 &&
|
|
34
|
+
buf[2] === 0x4e &&
|
|
35
|
+
buf[3] === 0x47,
|
|
36
|
+
},
|
|
37
|
+
'image/jpeg': {
|
|
38
|
+
ext: '.jpg',
|
|
39
|
+
check: (buf) =>
|
|
40
|
+
buf.length >= 3 &&
|
|
41
|
+
buf[0] === 0xff &&
|
|
42
|
+
buf[1] === 0xd8 &&
|
|
43
|
+
buf[2] === 0xff,
|
|
44
|
+
},
|
|
45
|
+
'image/webp': {
|
|
46
|
+
ext: '.webp',
|
|
47
|
+
check: (buf) =>
|
|
48
|
+
buf.length >= 12 &&
|
|
49
|
+
buf[0] === 0x52 &&
|
|
50
|
+
buf[1] === 0x49 &&
|
|
51
|
+
buf[2] === 0x46 &&
|
|
52
|
+
buf[3] === 0x46 && // 'RIFF'
|
|
53
|
+
buf[8] === 0x57 &&
|
|
54
|
+
buf[9] === 0x45 &&
|
|
55
|
+
buf[10] === 0x42 &&
|
|
56
|
+
buf[11] === 0x50, // 'WEBP'
|
|
57
|
+
},
|
|
58
|
+
'image/gif': {
|
|
59
|
+
ext: '.gif',
|
|
60
|
+
check: (buf) =>
|
|
61
|
+
buf.length >= 6 &&
|
|
62
|
+
buf[0] === 0x47 &&
|
|
63
|
+
buf[1] === 0x49 &&
|
|
64
|
+
buf[2] === 0x46 &&
|
|
65
|
+
buf[3] === 0x38, // 'GIF8'
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function getPastedDir() {
|
|
70
|
+
const dir = path.join(stateDir(), 'pasted');
|
|
71
|
+
ensureDir(dir);
|
|
72
|
+
return dir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Prunes the pasted directory:
|
|
77
|
+
* - Removes files older than maxAgeMs (default 24 hours).
|
|
78
|
+
* - Caps total file count (default 20) and total disk footprint (default 50 MB),
|
|
79
|
+
* deleting oldest files first.
|
|
80
|
+
*/
|
|
81
|
+
function cleanPastedDir({
|
|
82
|
+
dir = getPastedDir(),
|
|
83
|
+
maxFiles = MAX_SAVED_FILES,
|
|
84
|
+
maxBytes = MAX_TOTAL_BYTES,
|
|
85
|
+
maxAgeMs = MAX_AGE_MS,
|
|
86
|
+
now = Date.now(),
|
|
87
|
+
} = {}) {
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fileInfos = [];
|
|
96
|
+
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isFile()) continue;
|
|
99
|
+
const fullPath = path.join(dir, entry.name);
|
|
100
|
+
try {
|
|
101
|
+
const stat = fs.statSync(fullPath);
|
|
102
|
+
if (now - stat.mtimeMs > maxAgeMs) {
|
|
103
|
+
try { fs.unlinkSync(fullPath); } catch {}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
fileInfos.push({ path: fullPath, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sort oldest first
|
|
111
|
+
fileInfos.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
112
|
+
|
|
113
|
+
let totalSize = fileInfos.reduce((sum, f) => sum + f.size, 0);
|
|
114
|
+
|
|
115
|
+
while (fileInfos.length > maxFiles || totalSize > maxBytes) {
|
|
116
|
+
const oldest = fileInfos.shift();
|
|
117
|
+
if (!oldest) break;
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(oldest.path);
|
|
120
|
+
totalSize -= oldest.size;
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates base64 data, checks payload limits, sniffs magic bytes against declared MIME,
|
|
127
|
+
* writes to state storage with mode 0o600, and returns the absolute local path.
|
|
128
|
+
*/
|
|
129
|
+
function savePastedFile({ mime, dataBase64, dir = getPastedDir() }) {
|
|
130
|
+
if (typeof mime !== 'string' || !Object.hasOwn(MIME_CONFIG, mime)) {
|
|
131
|
+
throw new Error(`unsupported MIME type: ${mime}`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof dataBase64 !== 'string') {
|
|
134
|
+
throw new Error('missing or invalid dataBase64 payload');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const buf = Buffer.from(dataBase64, 'base64');
|
|
138
|
+
if (buf.length > MAX_PASTE_BYTES) {
|
|
139
|
+
throw new Error(`file size (${buf.length} bytes) exceeds maximum limit of 3 MB`);
|
|
140
|
+
}
|
|
141
|
+
if (buf.length === 0) {
|
|
142
|
+
throw new Error('file payload is empty');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const config = MIME_CONFIG[mime];
|
|
146
|
+
if (!config.check(buf)) {
|
|
147
|
+
throw new Error(`file magic bytes do not match declared MIME type ${mime}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Prune before writing new file
|
|
151
|
+
cleanPastedDir({ dir });
|
|
152
|
+
|
|
153
|
+
const fileName = `${crypto.randomUUID()}${config.ext}`;
|
|
154
|
+
const filePath = path.join(dir, fileName);
|
|
155
|
+
|
|
156
|
+
fs.writeFileSync(filePath, buf, { mode: 0o600, flag: 'wx' });
|
|
157
|
+
try {
|
|
158
|
+
fs.chmodSync(filePath, 0o600);
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
// Prune again after writing to strictly observe count/size ceiling
|
|
162
|
+
cleanPastedDir({ dir });
|
|
163
|
+
|
|
164
|
+
return filePath;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
MAX_PASTE_BYTES,
|
|
169
|
+
MAX_SAVED_FILES,
|
|
170
|
+
MAX_TOTAL_BYTES,
|
|
171
|
+
MAX_AGE_MS,
|
|
172
|
+
MIME_CONFIG,
|
|
173
|
+
getPastedDir,
|
|
174
|
+
cleanPastedDir,
|
|
175
|
+
savePastedFile,
|
|
176
|
+
};
|