@nightowne/tas-cli 1.1.1 → 2.1.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.
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Share Server — Temporary file sharing via HTTP
3
+ * Spins up a lightweight server that serves one-time download links
4
+ * for files stored in Telegram. Files are decrypted on-the-fly.
5
+ */
6
+
7
+ import http from 'http';
8
+ import crypto from 'crypto';
9
+ import path from 'path';
10
+ import { FileIndex } from '../db/index.js';
11
+ import { TelegramClient } from '../telegram/client.js';
12
+ import { Encryptor } from '../crypto/encryption.js';
13
+ import { Compressor } from '../utils/compression.js';
14
+ import { parseHeader, HEADER_SIZE } from '../utils/chunker.js';
15
+
16
+ /**
17
+ * Generate a secure random share token
18
+ */
19
+ export function generateToken() {
20
+ return crypto.randomBytes(16).toString('hex');
21
+ }
22
+
23
+ /**
24
+ * Parse duration string to milliseconds
25
+ * Supports: 1h, 24h, 7d, 30m, etc.
26
+ */
27
+ export function parseDuration(str) {
28
+ const match = str.match(/^(\d+)(m|h|d)$/);
29
+ if (!match) throw new Error(`Invalid duration: ${str}. Use format like 1h, 24h, 7d, 30m`);
30
+
31
+ const value = parseInt(match[1]);
32
+ const unit = match[2];
33
+
34
+ const multipliers = {
35
+ 'm': 60 * 1000,
36
+ 'h': 60 * 60 * 1000,
37
+ 'd': 24 * 60 * 60 * 1000
38
+ };
39
+
40
+ return value * multipliers[unit];
41
+ }
42
+
43
+ /**
44
+ * Format remaining time human-readable
45
+ */
46
+ function formatTimeLeft(expiresAt) {
47
+ const now = Date.now();
48
+ const diff = new Date(expiresAt).getTime() - now;
49
+
50
+ if (diff <= 0) return 'expired';
51
+
52
+ const hours = Math.floor(diff / (1000 * 60 * 60));
53
+ const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
54
+
55
+ if (hours > 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`;
56
+ if (hours > 0) return `${hours}h ${minutes}m`;
57
+ return `${minutes}m`;
58
+ }
59
+
60
+ /**
61
+ * Generate the download HTML page
62
+ */
63
+ function generateDownloadPage(share, fileRecord) {
64
+ const timeLeft = formatTimeLeft(share.expires_at);
65
+ const downloadsLeft = share.max_downloads - share.download_count;
66
+ const fileSize = fileRecord.original_size;
67
+ const sizeStr = fileSize > 1048576
68
+ ? `${(fileSize / 1048576).toFixed(1)} MB`
69
+ : fileSize > 1024
70
+ ? `${(fileSize / 1024).toFixed(1)} KB`
71
+ : `${fileSize} B`;
72
+
73
+ return `<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="UTF-8">
77
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
78
+ <title>TAS — Secure File Download</title>
79
+ <style>
80
+ * { margin: 0; padding: 0; box-sizing: border-box; }
81
+ body {
82
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
83
+ background: #0a0a0a;
84
+ color: #e0e0e0;
85
+ min-height: 100vh;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ }
90
+ .card {
91
+ background: #1a1a2e;
92
+ border: 1px solid #2a2a4a;
93
+ border-radius: 16px;
94
+ padding: 40px;
95
+ max-width: 420px;
96
+ width: 90%;
97
+ text-align: center;
98
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5);
99
+ }
100
+ .icon { font-size: 48px; margin-bottom: 16px; }
101
+ h1 { font-size: 20px; margin-bottom: 8px; color: #fff; }
102
+ .filename {
103
+ font-family: 'SF Mono', Monaco, monospace;
104
+ background: #0f0f23;
105
+ padding: 8px 16px;
106
+ border-radius: 8px;
107
+ margin: 16px 0;
108
+ font-size: 14px;
109
+ color: #7c83ff;
110
+ word-break: break-all;
111
+ }
112
+ .meta {
113
+ display: flex;
114
+ justify-content: center;
115
+ gap: 24px;
116
+ margin: 16px 0;
117
+ font-size: 13px;
118
+ color: #888;
119
+ }
120
+ .meta span { display: flex; align-items: center; gap: 4px; }
121
+ .btn {
122
+ display: inline-block;
123
+ background: linear-gradient(135deg, #667eea, #764ba2);
124
+ color: #fff;
125
+ text-decoration: none;
126
+ padding: 14px 40px;
127
+ border-radius: 10px;
128
+ font-size: 16px;
129
+ font-weight: 600;
130
+ margin-top: 20px;
131
+ transition: transform 0.2s, box-shadow 0.2s;
132
+ }
133
+ .btn:hover {
134
+ transform: translateY(-2px);
135
+ box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
136
+ }
137
+ .footer {
138
+ margin-top: 24px;
139
+ font-size: 11px;
140
+ color: #555;
141
+ }
142
+ .footer a { color: #667eea; text-decoration: none; }
143
+ </style>
144
+ </head>
145
+ <body>
146
+ <div class="card">
147
+ <div class="icon">🔐</div>
148
+ <h1>Secure File Share</h1>
149
+ <div class="filename">${fileRecord.filename}</div>
150
+ <div class="meta">
151
+ <span>📦 ${sizeStr}</span>
152
+ <span>⏳ ${timeLeft}</span>
153
+ <span>⬇️ ${downloadsLeft} left</span>
154
+ </div>
155
+ <a href="/d/${share.token}?download=1" class="btn">⬇ Download</a>
156
+ <div class="footer">
157
+ Encrypted with AES-256-GCM · Powered by <a href="https://github.com/ixchio/tas">TAS</a>
158
+ </div>
159
+ </div>
160
+ </body>
161
+ </html>`;
162
+ }
163
+
164
+ /**
165
+ * Generate expired/invalid page
166
+ */
167
+ function generateExpiredPage(reason = 'expired') {
168
+ const messages = {
169
+ expired: { icon: '⏰', title: 'Link Expired', desc: 'This download link has expired.' },
170
+ used: { icon: '✅', title: 'Already Downloaded', desc: 'This file has reached its download limit.' },
171
+ invalid: { icon: '❌', title: 'Invalid Link', desc: 'This download link is invalid or has been revoked.' }
172
+ };
173
+ const msg = messages[reason] || messages.invalid;
174
+
175
+ return `<!DOCTYPE html>
176
+ <html lang="en">
177
+ <head>
178
+ <meta charset="UTF-8">
179
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
180
+ <title>TAS — ${msg.title}</title>
181
+ <style>
182
+ * { margin: 0; padding: 0; box-sizing: border-box; }
183
+ body {
184
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
185
+ background: #0a0a0a;
186
+ color: #e0e0e0;
187
+ min-height: 100vh;
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: center;
191
+ }
192
+ .card {
193
+ background: #1a1a2e;
194
+ border: 1px solid #2a2a4a;
195
+ border-radius: 16px;
196
+ padding: 40px;
197
+ max-width: 420px;
198
+ width: 90%;
199
+ text-align: center;
200
+ }
201
+ .icon { font-size: 48px; margin-bottom: 16px; }
202
+ h1 { font-size: 20px; margin-bottom: 8px; color: #fff; }
203
+ p { color: #888; font-size: 14px; }
204
+ </style>
205
+ </head>
206
+ <body>
207
+ <div class="card">
208
+ <div class="icon">${msg.icon}</div>
209
+ <h1>${msg.title}</h1>
210
+ <p>${msg.desc}</p>
211
+ </div>
212
+ </body>
213
+ </html>`;
214
+ }
215
+
216
+ export class ShareServer {
217
+ constructor(options) {
218
+ this.dataDir = options.dataDir;
219
+ this.password = options.password;
220
+ this.config = options.config;
221
+ this.port = options.port || 3000;
222
+ this.host = options.host || '0.0.0.0';
223
+
224
+ this.db = null;
225
+ this.client = null;
226
+ this.encryptor = null;
227
+ this.compressor = null;
228
+ this.server = null;
229
+ }
230
+
231
+ async initialize() {
232
+ this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
233
+ this.db.init();
234
+
235
+ this.client = new TelegramClient(this.dataDir);
236
+ await this.client.initialize(this.config.botToken);
237
+ this.client.setChatId(this.config.chatId);
238
+
239
+ this.encryptor = new Encryptor(this.password);
240
+ this.compressor = new Compressor();
241
+ }
242
+
243
+ /**
244
+ * Stream a decrypted file from Telegram directly to the HTTP response
245
+ */
246
+ async streamToResponse(fileRecord, res) {
247
+ const chunks = this.db.getChunks(fileRecord.id);
248
+ if (chunks.length === 0) throw new Error('No chunks found');
249
+
250
+ // Pre-sort chunks by index so we download them in correct order
251
+ chunks.sort((a, b) => a.chunk_index - b.chunk_index);
252
+
253
+ // Get total size from first chunk's header
254
+ const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
255
+ const header = parseHeader(firstChunkData);
256
+ let wasCompressed = header.compressed;
257
+
258
+ // Prepare streams
259
+ const decryptStream = this.encryptor.getDecryptStream();
260
+ const decompressStream = this.compressor.getDecompressStream(wasCompressed);
261
+
262
+ // We need a Readable stream that will lazily fetch chunks from Telegram
263
+ const { Readable } = await import('stream');
264
+ const { pipeline } = await import('stream/promises');
265
+
266
+ const self = this;
267
+ let currentChunkIndex = 0;
268
+ let preloadedFirstChunk = firstChunkData;
269
+
270
+ const downloadStream = new Readable({
271
+ async read() {
272
+ try {
273
+ if (currentChunkIndex >= chunks.length) {
274
+ this.push(null); // End of stream
275
+ return;
276
+ }
277
+
278
+ const chunk = chunks[currentChunkIndex];
279
+ let data;
280
+
281
+ if (currentChunkIndex === 0 && preloadedFirstChunk) {
282
+ data = preloadedFirstChunk;
283
+ preloadedFirstChunk = null;
284
+ } else {
285
+ data = await self.client.downloadFile(chunk.file_telegram_id);
286
+ }
287
+
288
+ // Strip header before pushing
289
+ const payload = data.subarray(HEADER_SIZE);
290
+ this.push(payload);
291
+
292
+ currentChunkIndex++;
293
+ } catch (err) {
294
+ this.destroy(err);
295
+ }
296
+ }
297
+ });
298
+
299
+ // Pipeline: Download from Telegram -> Decrypt -> Decompress -> HTTP Response
300
+ await pipeline(downloadStream, decryptStream, decompressStream, res);
301
+ }
302
+
303
+ /**
304
+ * Handle incoming HTTP requests
305
+ */
306
+ async handleRequest(req, res) {
307
+ const url = new URL(req.url, `http://${req.headers.host}`);
308
+
309
+ // Route: GET /d/:token
310
+ const downloadMatch = url.pathname.match(/^\/d\/([a-f0-9]+)$/);
311
+
312
+ if (!downloadMatch) {
313
+ res.writeHead(404, { 'Content-Type': 'text/html' });
314
+ res.end(generateExpiredPage('invalid'));
315
+ return;
316
+ }
317
+
318
+ const token = downloadMatch[1];
319
+ const wantDownload = url.searchParams.get('download') === '1';
320
+
321
+ try {
322
+ // Clean expired shares first
323
+ this.db.cleanExpiredShares();
324
+
325
+ // Look up share
326
+ const share = this.db.getShare(token);
327
+
328
+ if (!share) {
329
+ res.writeHead(410, { 'Content-Type': 'text/html' });
330
+ res.end(generateExpiredPage('invalid'));
331
+ return;
332
+ }
333
+
334
+ // Check expiry
335
+ if (new Date(share.expires_at) < new Date()) {
336
+ res.writeHead(410, { 'Content-Type': 'text/html' });
337
+ res.end(generateExpiredPage('expired'));
338
+ return;
339
+ }
340
+
341
+ // Check download limit
342
+ if (share.download_count >= share.max_downloads) {
343
+ res.writeHead(410, { 'Content-Type': 'text/html' });
344
+ res.end(generateExpiredPage('used'));
345
+ return;
346
+ }
347
+
348
+ // Get file record
349
+ const fileRecord = this.db.db.prepare('SELECT * FROM files WHERE id = ?').get(share.file_id);
350
+ if (!fileRecord) {
351
+ res.writeHead(404, { 'Content-Type': 'text/html' });
352
+ res.end(generateExpiredPage('invalid'));
353
+ return;
354
+ }
355
+
356
+ if (!wantDownload) {
357
+ // Show download page
358
+ res.writeHead(200, { 'Content-Type': 'text/html' });
359
+ res.end(generateDownloadPage(share, fileRecord));
360
+ return;
361
+ }
362
+
363
+ // Increment download count
364
+ this.db.incrementShareDownload(token);
365
+
366
+ // Determine content type
367
+ const ext = path.extname(fileRecord.filename).toLowerCase();
368
+ const contentTypes = {
369
+ '.pdf': 'application/pdf',
370
+ '.png': 'image/png',
371
+ '.jpg': 'image/jpeg',
372
+ '.jpeg': 'image/jpeg',
373
+ '.gif': 'image/gif',
374
+ '.txt': 'text/plain',
375
+ '.json': 'application/json',
376
+ '.zip': 'application/zip',
377
+ '.mp4': 'video/mp4',
378
+ '.mp3': 'audio/mpeg'
379
+ };
380
+ const contentType = contentTypes[ext] || 'application/octet-stream';
381
+
382
+ res.writeHead(200, {
383
+ 'Content-Type': contentType,
384
+ 'Content-Disposition': `attachment; filename="${fileRecord.filename}"`,
385
+ 'Content-Length': fileRecord.original_size
386
+ });
387
+
388
+ // Download the file from Telegram, decrypt, decompress and stream directly to 'res'
389
+ await this.streamToResponse(fileRecord, res);
390
+
391
+ } catch (err) {
392
+ console.error('Share server error:', err.message);
393
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
394
+ res.end('Internal server error');
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Start the HTTP server
400
+ */
401
+ start() {
402
+ return new Promise((resolve, reject) => {
403
+ this.server = http.createServer((req, res) => {
404
+ this.handleRequest(req, res).catch(err => {
405
+ console.error('Request error:', err);
406
+ res.writeHead(500);
407
+ res.end('Internal error');
408
+ });
409
+ });
410
+
411
+ this.server.on('error', (err) => {
412
+ if (err.code === 'EADDRINUSE') {
413
+ reject(new Error(`Port ${this.port} is already in use. Try --port <other-port>`));
414
+ } else {
415
+ reject(err);
416
+ }
417
+ });
418
+
419
+ this.server.listen(this.port, this.host, () => {
420
+ resolve();
421
+ });
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Stop the HTTP server
427
+ */
428
+ stop() {
429
+ return new Promise((resolve) => {
430
+ if (this.server) {
431
+ this.server.close(() => {
432
+ if (this.db) this.db.close();
433
+ resolve();
434
+ });
435
+ } else {
436
+ if (this.db) this.db.close();
437
+ resolve();
438
+ }
439
+ });
440
+ }
441
+ }
package/src/sync/sync.js CHANGED
@@ -29,6 +29,7 @@ export class SyncEngine extends EventEmitter {
29
29
  this.dataDir = options.dataDir;
30
30
  this.password = options.password;
31
31
  this.config = options.config;
32
+ this.limitRate = options.limitRate || null;
32
33
  this.watchers = new Map(); // path -> FSWatcher
33
34
  this.pendingChanges = new Map(); // path -> timeout
34
35
  this.db = null;
@@ -98,53 +99,70 @@ export class SyncEngine extends EventEmitter {
98
99
  let uploaded = 0;
99
100
  let skipped = 0;
100
101
 
101
- for (const file of files) {
102
- const existing = stateMap.get(file.relativePath);
102
+ // Process files with concurrency limit
103
+ const CONCURRENCY = 4;
104
+ const queue = [...files];
105
+ const promises = [];
103
106
 
104
- // Check if file has changed (by mtime)
105
- if (existing && existing.mtime >= file.mtime) {
106
- skipped++;
107
- continue;
108
- }
107
+ const worker = async () => {
108
+ while (queue.length > 0) {
109
+ const file = queue.shift();
110
+ const existing = stateMap.get(file.relativePath);
109
111
 
110
- // Calculate hash to detect actual changes
111
- const hash = await hashFile(file.path);
112
+ // Check if file has changed (by mtime)
113
+ if (existing && existing.mtime >= file.mtime) {
114
+ skipped++;
115
+ continue;
116
+ }
112
117
 
113
- if (existing && existing.file_hash === hash) {
114
- // File unchanged, just update mtime
115
- this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
116
- skipped++;
117
- continue;
118
- }
118
+ // Calculate hash to detect actual changes
119
+ const hash = await hashFile(file.path);
119
120
 
120
- // File is new or changed - upload it
121
- try {
122
- this.emit('file-upload-start', { file: file.relativePath });
121
+ if (existing && existing.file_hash === hash) {
122
+ // File unchanged, just update mtime
123
+ this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
124
+ skipped++;
125
+ continue;
126
+ }
123
127
 
124
- await processFile(file.path, {
125
- password: this.password,
126
- dataDir: this.dataDir,
127
- customName: file.relativePath, // Use relative path as name
128
- config: this.config,
129
- onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
130
- });
128
+ // File is new or changed - upload it
129
+ try {
130
+ this.emit('file-upload-start', { file: file.relativePath });
131
131
 
132
- // Update sync state
133
- this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
134
- uploaded++;
132
+ await processFile(file.path, {
133
+ password: this.password,
134
+ dataDir: this.dataDir,
135
+ customName: file.relativePath, // Use relative path as name
136
+ config: this.config,
137
+ limitRate: this.limitRate ? Math.floor(this.limitRate / CONCURRENCY) : null,
138
+ onProgress: (msg) => this.emit('progress', { file: file.relativePath, message: msg })
139
+ });
135
140
 
136
- this.emit('file-upload-complete', { file: file.relativePath });
137
- } catch (err) {
138
- // File might already exist, skip
139
- if (err.message.includes('duplicate')) {
141
+ // Update sync state
140
142
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
141
- skipped++;
142
- } else {
143
- this.emit('file-upload-error', { file: file.relativePath, error: err.message });
143
+ uploaded++;
144
+
145
+ this.emit('file-upload-complete', { file: file.relativePath });
146
+ } catch (err) {
147
+ // File might already exist, skip
148
+ if (err.message.includes('duplicate')) {
149
+ this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
150
+ skipped++;
151
+ } else {
152
+ // Sleep briefly on non-duplicate error (potential rate limits)
153
+ await new Promise(r => setTimeout(r, 2000));
154
+ this.emit('file-upload-error', { file: file.relativePath, error: err.message });
155
+ }
144
156
  }
145
157
  }
158
+ };
159
+
160
+ for (let i = 0; i < CONCURRENCY; i++) {
161
+ promises.push(worker());
146
162
  }
147
163
 
164
+ await Promise.all(promises);
165
+
148
166
  this.emit('sync-complete', { folder: folderPath, uploaded, skipped });
149
167
 
150
168
  return { uploaded, skipped };
@@ -206,6 +224,7 @@ export class SyncEngine extends EventEmitter {
206
224
  dataDir: this.dataDir,
207
225
  customName: filename,
208
226
  config: this.config,
227
+ limitRate: this.limitRate,
209
228
  onProgress: (msg) => this.emit('progress', { file: filename, message: msg })
210
229
  });
211
230
 
@@ -6,6 +6,7 @@
6
6
  import TelegramBot from 'node-telegram-bot-api';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
+ import { pipeline } from 'stream/promises';
9
10
 
10
11
  export class TelegramClient {
11
12
  constructor(dataDir) {
@@ -18,8 +19,12 @@ export class TelegramClient {
18
19
  * Initialize with bot token
19
20
  * Get token from @BotFather on Telegram
20
21
  */
21
- async initialize(token) {
22
- this.bot = new TelegramBot(token, { polling: false });
22
+ async initialize(token, customApiUrl = null) {
23
+ const options = { polling: false };
24
+ if (customApiUrl) {
25
+ options.baseApiUrl = customApiUrl;
26
+ }
27
+ this.bot = new TelegramBot(token, options);
23
28
 
24
29
  // Verify the token works
25
30
  try {
@@ -75,30 +80,43 @@ export class TelegramClient {
75
80
  * Send a file to the storage chat
76
81
  * Telegram supports up to 2GB for documents!
77
82
  */
78
- async sendFile(filePath, caption = '') {
83
+ async sendFile(filePath, caption = '', options = {}) {
79
84
  if (!this.chatId) {
80
85
  throw new Error('Chat ID not set. Run init first.');
81
86
  }
82
87
 
83
- const fileStream = fs.createReadStream(filePath);
84
- const filename = path.basename(filePath);
88
+ if (!fs.existsSync(filePath)) {
89
+ throw new Error(`File not found: ${filePath}`);
90
+ }
85
91
 
86
- const message = await this.bot.sendDocument(this.chatId, fileStream, {
87
- caption: caption
88
- }, {
89
- filename: filename,
90
- contentType: 'application/octet-stream'
91
- });
92
+ try {
93
+ let fileStream = fs.createReadStream(filePath);
94
+ const filename = path.basename(filePath);
95
+
96
+ if (options.limitRate) {
97
+ const { Throttle } = await import('../utils/throttle.js');
98
+ fileStream = fileStream.pipe(new Throttle(options.limitRate));
99
+ }
100
+
101
+ const message = await this.bot.sendDocument(this.chatId, fileStream, {
102
+ caption: caption
103
+ }, {
104
+ filename: filename,
105
+ contentType: 'application/octet-stream'
106
+ });
92
107
 
93
- return {
94
- messageId: message.message_id,
95
- fileId: message.document.file_id,
96
- timestamp: message.date
97
- };
108
+ return {
109
+ messageId: message.message_id,
110
+ fileId: message.document.file_id,
111
+ timestamp: message.date
112
+ };
113
+ } catch (err) {
114
+ throw new Error(`Failed to upload to Telegram: ${err.message}`);
115
+ }
98
116
  }
99
117
 
100
118
  /**
101
- * Download a file from Telegram
119
+ * Download a file from Telegram (In-Memory buffer)
102
120
  */
103
121
  async downloadFile(fileId) {
104
122
  // Get file path from Telegram servers
@@ -116,6 +134,16 @@ export class TelegramClient {
116
134
  return Buffer.concat(chunks);
117
135
  }
118
136
 
137
+ /**
138
+ * Efficiently download a file from Telegram straight to disk
139
+ */
140
+ async downloadFileToPath(fileId, destPath) {
141
+ const fileStream = await this.bot.getFileStream(fileId);
142
+ const writeStream = fs.createWriteStream(destPath);
143
+ await pipeline(fileStream, writeStream);
144
+ return destPath;
145
+ }
146
+
119
147
  /**
120
148
  * Delete a message (optional cleanup)
121
149
  */
@@ -91,7 +91,17 @@ export function createHeader(filename, originalSize, chunkIndex, totalChunks, fl
91
91
  offset += 2;
92
92
 
93
93
  // Filename length
94
- const filenameBytes = Buffer.from(filename, 'utf-8').subarray(0, 42);
94
+ let filenameBytes = Buffer.from(filename, 'utf-8');
95
+ if (filenameBytes.length > 42) {
96
+ // We carefully truncate by chars instead of bytes to avoid splitting a UTF-8 character in half!
97
+ let truncated = filename;
98
+ while (Buffer.from(truncated, 'utf-8').length > 42) {
99
+ // Remove one character at a time from the end
100
+ truncated = truncated.slice(0, -1);
101
+ }
102
+ filenameBytes = Buffer.from(truncated, 'utf-8');
103
+ }
104
+
95
105
  header.writeUInt16LE(filenameBytes.length, offset);
96
106
  offset += 2;
97
107