@nightowne/tas-cli 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fuse/mount.js CHANGED
@@ -5,21 +5,38 @@
5
5
  * This is the killer feature - use Telegram like a regular folder!
6
6
  */
7
7
 
8
- import Fuse from 'fuse-native';
9
8
  import path from 'path';
10
9
  import fs from 'fs';
10
+ import crypto from 'crypto';
11
+ import { processFile } from '../index.js';
12
+
13
+ let Fuse;
14
+ try {
15
+ Fuse = (await import('fuse-native')).default;
16
+ } catch {
17
+ // fuse-native is optional — unavailable on ARM64 or systems without libfuse
18
+ }
11
19
  import { TelegramClient } from '../telegram/client.js';
12
20
  import { Encryptor } from '../crypto/encryption.js';
13
21
  import { Compressor } from '../utils/compression.js';
14
22
  import { FileIndex } from '../db/index.js';
15
23
  import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
16
24
 
17
- // File cache for performance (avoid re-downloading)
18
- const fileCache = new Map();
19
25
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
26
+ const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
20
27
 
21
28
  export class TelegramFS {
22
29
  constructor(options) {
30
+ if (!Fuse) {
31
+ throw new Error(
32
+ 'fuse-native is not available on this system.\n' +
33
+ ' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
34
+ ' On macOS: brew install macfuse && npm install fuse-native\n' +
35
+ ' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
36
+ ' All other TAS commands (push, pull, sync, share) work without FUSE.'
37
+ );
38
+ }
39
+
23
40
  this.dataDir = options.dataDir;
24
41
  this.password = options.password;
25
42
  this.config = options.config;
@@ -35,6 +52,9 @@ export class TelegramFS {
35
52
 
36
53
  // Pending writes buffer
37
54
  this.writeBuffers = new Map();
55
+
56
+ // Instance-level file cache to prevent cross-talk
57
+ this.fileCache = new Map();
38
58
  }
39
59
 
40
60
  async initialize() {
@@ -70,7 +90,7 @@ export class TelegramFS {
70
90
  mtime: new Date(),
71
91
  atime: new Date(),
72
92
  ctime: new Date(),
73
- size: wb.data.length,
93
+ size: wb.size,
74
94
  mode: 0o100644, // regular file
75
95
  uid: process.getuid?.() || 0,
76
96
  gid: process.getgid?.() || 0
@@ -104,9 +124,14 @@ export class TelegramFS {
104
124
  }
105
125
 
106
126
  const files = this.db.listAll();
107
- const names = files.map(f => f.filename);
127
+ const nameSet = new Set(files.map(f => f.filename));
128
+
129
+ // Include files currently being written (not yet released/uploaded)
130
+ for (const name of this.writeBuffers.keys()) {
131
+ nameSet.add(name);
132
+ }
108
133
 
109
- return cb(0, names);
134
+ return cb(0, [...nameSet]);
110
135
  }
111
136
 
112
137
  /**
@@ -139,9 +164,8 @@ export class TelegramFS {
139
164
  // Check write buffers first
140
165
  const wb = this.writeBuffers.get(filename);
141
166
  if (wb) {
142
- const slice = wb.data.subarray(position, position + length);
143
- slice.copy(buffer);
144
- return cb(slice.length);
167
+ const bytesRead = fs.readSync(wb.fd, buffer, 0, length, position);
168
+ return cb(bytesRead);
145
169
  }
146
170
 
147
171
  // Check cache first
@@ -166,34 +190,55 @@ export class TelegramFS {
166
190
  }
167
191
 
168
192
  /**
169
- * Write to a file (buffers until release)
193
+ * Helper to get or create a disk-buffered write structure
170
194
  */
171
- write(filepath, fd, buffer, length, position, cb) {
172
- const filename = path.basename(filepath);
195
+ getOrCreateWriteBuffer(filename) {
196
+ if (this.writeBuffers.has(filename)) {
197
+ return this.writeBuffers.get(filename);
198
+ }
173
199
 
174
- // Get or create write buffer
175
- if (!this.writeBuffers.has(filename)) {
176
- this.writeBuffers.set(filename, {
177
- data: Buffer.alloc(0),
178
- modified: true
179
- });
200
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
201
+ if (!fs.existsSync(fuseTmpDir)) {
202
+ fs.mkdirSync(fuseTmpDir, { recursive: true });
180
203
  }
181
204
 
182
- const wb = this.writeBuffers.get(filename);
205
+ const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
206
+ const fdDisk = fs.openSync(tmpPath, 'w+');
183
207
 
184
- // Expand buffer if needed
185
- const newSize = Math.max(wb.data.length, position + length);
186
- if (newSize > wb.data.length) {
187
- const newBuf = Buffer.alloc(newSize);
188
- wb.data.copy(newBuf);
189
- wb.data = newBuf;
208
+ // Check if there is an existing file cached
209
+ const cachedPath = this.getCached(filename);
210
+ let size = 0;
211
+ if (cachedPath && fs.existsSync(cachedPath)) {
212
+ const content = fs.readFileSync(cachedPath);
213
+ fs.writeSync(fdDisk, content, 0, content.length, 0);
214
+ size = content.length;
190
215
  }
191
216
 
192
- // Copy incoming data
193
- buffer.copy(wb.data, position, 0, length);
194
- wb.modified = true;
217
+ const wb = {
218
+ tmpPath,
219
+ fd: fdDisk,
220
+ size,
221
+ modified: true
222
+ };
223
+ this.writeBuffers.set(filename, wb);
224
+ return wb;
225
+ }
195
226
 
196
- return cb(length);
227
+ /**
228
+ * Write to a file (buffers until release)
229
+ */
230
+ write(filepath, fd, buffer, length, position, cb) {
231
+ const filename = path.basename(filepath);
232
+ try {
233
+ const wb = this.getOrCreateWriteBuffer(filename);
234
+ fs.writeSync(wb.fd, buffer, 0, length, position);
235
+ wb.size = Math.max(wb.size, position + length);
236
+ wb.modified = true;
237
+ return cb(length);
238
+ } catch (err) {
239
+ console.error('[FUSE] Write error:', err.message);
240
+ return cb(Fuse.EIO);
241
+ }
197
242
  }
198
243
 
199
244
  /**
@@ -204,9 +249,19 @@ export class TelegramFS {
204
249
 
205
250
  console.log(`[FUSE] Creating file: ${filename}`);
206
251
 
207
- // Initialize empty write buffer
252
+ // Initialize empty disk-buffered write
253
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
254
+ if (!fs.existsSync(fuseTmpDir)) {
255
+ fs.mkdirSync(fuseTmpDir, { recursive: true });
256
+ }
257
+
258
+ const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
259
+ const fdDisk = fs.openSync(tmpPath, 'w+');
260
+
208
261
  this.writeBuffers.set(filename, {
209
- data: Buffer.alloc(0),
262
+ tmpPath,
263
+ fd: fdDisk,
264
+ size: 0,
210
265
  modified: true,
211
266
  isNew: true
212
267
  });
@@ -228,15 +283,26 @@ export class TelegramFS {
228
283
  const filename = path.basename(filepath);
229
284
 
230
285
  const wb = this.writeBuffers.get(filename);
231
- if (!wb || !wb.modified) {
286
+ if (!wb) {
287
+ return cb(0);
288
+ }
289
+
290
+ if (!wb.modified) {
291
+ try { fs.closeSync(wb.fd); } catch (e) {}
292
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
293
+ this.writeBuffers.delete(filename);
232
294
  return cb(0);
233
295
  }
234
296
 
235
297
  try {
236
- // Upload to Telegram
237
- await this.uploadFile(filename, wb.data);
298
+ // Close fd so we can read and upload cleanly
299
+ try { fs.closeSync(wb.fd); } catch (e) {}
300
+
301
+ // Upload to Telegram from the temp path
302
+ await this.uploadFile(filename, wb.tmpPath);
238
303
 
239
- // Clear write buffer
304
+ // Clean up temp file
305
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
240
306
  this.writeBuffers.delete(filename);
241
307
 
242
308
  // Invalidate cache
@@ -244,7 +310,9 @@ export class TelegramFS {
244
310
 
245
311
  return cb(0);
246
312
  } catch (err) {
247
- console.error('Release error:', err.message);
313
+ console.error('[FUSE] Release error:', err.message);
314
+ try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
315
+ this.writeBuffers.delete(filename);
248
316
  return cb(Fuse.EIO);
249
317
  }
250
318
  }
@@ -261,7 +329,7 @@ export class TelegramFS {
261
329
  }
262
330
 
263
331
  try {
264
- // Delete from Telegram (optional - could just remove from index)
332
+ // Delete from Telegram
265
333
  const chunks = this.db.getChunks(file.id);
266
334
  for (const chunk of chunks) {
267
335
  await this.client.deleteMessage(chunk.message_id);
@@ -275,7 +343,7 @@ export class TelegramFS {
275
343
 
276
344
  return cb(0);
277
345
  } catch (err) {
278
- console.error('Unlink error:', err.message);
346
+ console.error('[FUSE] Unlink error:', err.message);
279
347
  return cb(Fuse.EIO);
280
348
  }
281
349
  }
@@ -297,10 +365,10 @@ export class TelegramFS {
297
365
  .run(newName, file.id);
298
366
 
299
367
  // Update cache key
300
- const cached = fileCache.get(oldName);
368
+ const cached = this.fileCache.get(oldName);
301
369
  if (cached) {
302
- fileCache.delete(oldName);
303
- fileCache.set(newName, cached);
370
+ this.fileCache.delete(oldName);
371
+ this.fileCache.set(newName, cached);
304
372
  }
305
373
 
306
374
  return cb(0);
@@ -311,35 +379,16 @@ export class TelegramFS {
311
379
  */
312
380
  truncate(filepath, size, cb) {
313
381
  const filename = path.basename(filepath);
314
-
315
- // Get or load into write buffer
316
- if (!this.writeBuffers.has(filename)) {
317
- const cachedPath = this.getCached(filename);
318
- if (cachedPath) {
319
- this.writeBuffers.set(filename, {
320
- data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
321
- modified: true
322
- });
323
- } else {
324
- this.writeBuffers.set(filename, {
325
- data: Buffer.alloc(0),
326
- modified: true
327
- });
328
- }
329
- }
330
-
331
- const wb = this.writeBuffers.get(filename);
332
-
333
- if (size < wb.data.length) {
334
- wb.data = wb.data.subarray(0, size);
335
- } else if (size > wb.data.length) {
336
- const newBuf = Buffer.alloc(size);
337
- wb.data.copy(newBuf);
338
- wb.data = newBuf;
382
+ try {
383
+ const wb = this.getOrCreateWriteBuffer(filename);
384
+ fs.ftruncateSync(wb.fd, size);
385
+ wb.size = size;
386
+ wb.modified = true;
387
+ return cb(0);
388
+ } catch (err) {
389
+ console.error('[FUSE] Truncate error:', err.message);
390
+ return cb(Fuse.EIO);
339
391
  }
340
-
341
- wb.modified = true;
342
- return cb(0);
343
392
  }
344
393
 
345
394
  // ============== Helper Methods ==============
@@ -421,10 +470,7 @@ export class TelegramFS {
421
470
  return outputPath;
422
471
  }
423
472
 
424
- async uploadFile(filename, data) {
425
- const { hashData } = await import('../crypto/encryption.js');
426
- const hash = hashData(data);
427
-
473
+ async uploadFile(filename, tempPath) {
428
474
  // Check if already exists by name
429
475
  const existingByName = this.db.findByName(filename);
430
476
  if (existingByName) {
@@ -436,57 +482,17 @@ export class TelegramFS {
436
482
  this.db.delete(existingByName.id);
437
483
  }
438
484
 
439
- // Check if already exists by hash (same content, different name)
440
- const existingByHash = this.db.findByHash(hash);
441
- if (existingByHash) {
442
- // Same content already exists, just skip
443
- console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
444
- return;
445
- }
446
-
447
- // Compress
448
- const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
449
-
450
- // Encrypt
451
- const encryptedData = this.encryptor.encrypt(compressedData);
452
-
453
- // Create temp file with header
454
- const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
455
- if (!fs.existsSync(tempDir)) {
456
- fs.mkdirSync(tempDir, { recursive: true });
457
- }
458
-
459
- const flags = compressed ? 1 : 0;
460
- const header = createHeader(filename, data.length, 0, 1, flags);
461
- const fileData = Buffer.concat([header, encryptedData]);
462
-
463
- const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
464
- fs.writeFileSync(tempPath, fileData);
465
-
466
- // Upload to Telegram
467
- const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
468
-
469
- // Add to index
470
- const fileId = this.db.addFile({
471
- filename,
472
- hash,
473
- originalSize: data.length,
474
- storedSize: encryptedData.length,
475
- chunks: 1,
476
- compressed
485
+ // Run the standard chunked upload pipeline (this handles chunking, encryption, compression, and DB entry)
486
+ await processFile(tempPath, {
487
+ password: this.password,
488
+ dataDir: this.dataDir,
489
+ customName: filename,
490
+ config: this.config
477
491
  });
478
-
479
- this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
480
- this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
481
- .run(result.fileId, fileId, 0);
482
-
483
- // Cleanup
484
- fs.unlinkSync(tempPath);
485
- try { fs.rmdirSync(tempDir); } catch (e) { }
486
492
  }
487
493
 
488
494
  getCached(filename) {
489
- const entry = fileCache.get(filename);
495
+ const entry = this.fileCache.get(filename);
490
496
  if (!entry) return null;
491
497
 
492
498
  if (Date.now() - entry.timestamp > CACHE_TTL) {
@@ -494,7 +500,7 @@ export class TelegramFS {
494
500
  try {
495
501
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
496
502
  } catch (e) { }
497
- fileCache.delete(filename);
503
+ this.fileCache.delete(filename);
498
504
  return null;
499
505
  }
500
506
 
@@ -504,19 +510,36 @@ export class TelegramFS {
504
510
  }
505
511
 
506
512
  setCache(filename, cachePath) {
507
- fileCache.set(filename, {
513
+ // Evict oldest entry if cache is full
514
+ if (this.fileCache.size >= CACHE_MAX_ENTRIES) {
515
+ let oldestKey = null;
516
+ let oldestTime = Infinity;
517
+ for (const [key, entry] of this.fileCache) {
518
+ if (entry.timestamp < oldestTime) {
519
+ oldestTime = entry.timestamp;
520
+ oldestKey = key;
521
+ }
522
+ }
523
+ if (oldestKey) {
524
+ const evicted = this.fileCache.get(oldestKey);
525
+ try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
526
+ this.fileCache.delete(oldestKey);
527
+ }
528
+ }
529
+
530
+ this.fileCache.set(filename, {
508
531
  path: cachePath,
509
532
  timestamp: Date.now()
510
533
  });
511
534
  }
512
535
 
513
536
  invalidateCache(filename) {
514
- const entry = fileCache.get(filename);
537
+ const entry = this.fileCache.get(filename);
515
538
  if (entry) {
516
539
  try {
517
540
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
518
541
  } catch (e) { }
519
- fileCache.delete(filename);
542
+ this.fileCache.delete(filename);
520
543
  }
521
544
  }
522
545
 
@@ -529,6 +552,17 @@ export class TelegramFS {
529
552
  fs.mkdirSync(this.mountPoint, { recursive: true });
530
553
  }
531
554
 
555
+ // Clean up any stray temp files from previous sessions
556
+ const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
557
+ if (fs.existsSync(fuseTmpDir)) {
558
+ try {
559
+ const files = fs.readdirSync(fuseTmpDir);
560
+ for (const file of files) {
561
+ fs.unlinkSync(path.join(fuseTmpDir, file));
562
+ }
563
+ } catch (e) {}
564
+ }
565
+
532
566
  const ops = {
533
567
  getattr: this.getattr.bind(this),
534
568
  readdir: this.readdir.bind(this),
package/src/index.js CHANGED
@@ -8,7 +8,7 @@ import path from 'path';
8
8
  import { pipeline } from 'stream/promises';
9
9
  import { Encryptor, hashFile } from './crypto/encryption.js';
10
10
  import { Compressor } from './utils/compression.js';
11
- import { Chunker, createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
11
+ import { createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
12
12
  import { TelegramClient } from './telegram/client.js';
13
13
  import { FileIndex } from './db/index.js';
14
14
 
@@ -72,15 +72,23 @@ export async function processFile(filePath, options) {
72
72
  if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
73
73
  let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
74
74
 
75
- // Register file in DB
76
- const fileId = db.addFile({
77
- filename,
78
- hash,
79
- originalSize,
80
- storedSize: 0, // Will update later
81
- chunks: estimatedChunks,
82
- compressed
83
- });
75
+ // Register file in DB inside a transaction for atomicity
76
+ db.db.exec('BEGIN');
77
+ let fileId;
78
+ try {
79
+ fileId = db.addFile({
80
+ filename,
81
+ hash,
82
+ originalSize,
83
+ storedSize: 0, // Will update later
84
+ chunks: estimatedChunks,
85
+ compressed
86
+ });
87
+ } catch (err) {
88
+ db.db.exec('ROLLBACK');
89
+ db.close();
90
+ throw err;
91
+ }
84
92
 
85
93
  onProgress?.('Processing and uploading streams...');
86
94
  let uploadedBytes = 0;
@@ -162,22 +170,33 @@ export async function processFile(filePath, options) {
162
170
  }
163
171
  });
164
172
 
165
- const readStream = fs.createReadStream(filePath);
173
+ try {
174
+ const readStream = fs.createReadStream(filePath);
166
175
 
167
- // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
168
- await pipeline(readStream, compressStream, encryptStream, chunkingStream);
176
+ // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
177
+ await pipeline(readStream, compressStream, encryptStream, chunkingStream);
178
+ } catch (pipelineErr) {
179
+ // Pipeline failed — roll back the DB transaction so no orphaned rows remain
180
+ try { db.db.exec('ROLLBACK'); } catch (e) { /* already rolled back */ }
181
+ db.close();
182
+ throw pipelineErr;
183
+ }
169
184
 
170
185
  // Update the DB with the final accurate values
171
186
  db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
172
187
  .run(totalStoredSize, chunkIndex, fileId);
173
188
 
189
+ // Commit the transaction — all DB rows are now permanent
190
+ db.db.exec('COMMIT');
191
+
174
192
  db.close();
175
193
 
176
- // Clean up temp dir
194
+ // Clean up temp dir (only if empty)
177
195
  try {
178
- fs.rmdirSync(tempDir);
196
+ const remaining = fs.readdirSync(tempDir);
197
+ if (remaining.length === 0) fs.rmdirSync(tempDir);
179
198
  } catch (e) {
180
- // Ignore if not empty
199
+ // Ignore cleanup errors
181
200
  }
182
201
 
183
202
  return {
@@ -292,8 +311,16 @@ export async function retrieveFile(fileRecord, options) {
292
311
 
293
312
  const finalStats = fs.statSync(outputPath);
294
313
 
314
+ // Verify file integrity by comparing hash
315
+ onProgress?.('Verifying file integrity...');
316
+ const downloadedHash = await hashFile(outputPath);
317
+ if (fileRecord.hash && downloadedHash !== fileRecord.hash) {
318
+ throw new Error(`Integrity check failed: expected hash ${fileRecord.hash.substring(0, 12)}..., got ${downloadedHash.substring(0, 12)}...`);
319
+ }
320
+
295
321
  return {
296
322
  path: outputPath,
297
- size: finalStats.size
323
+ size: finalStats.size,
324
+ verified: true
298
325
  };
299
326
  }
@@ -57,6 +57,25 @@ function formatTimeLeft(expiresAt) {
57
57
  return `${minutes}m`;
58
58
  }
59
59
 
60
+ /**
61
+ * Escape HTML special characters to prevent XSS
62
+ */
63
+ function escapeHtml(str) {
64
+ return String(str)
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;')
69
+ .replace(/'/g, '&#x27;');
70
+ }
71
+
72
+ /**
73
+ * Sanitize filename for Content-Disposition header (RFC 6266)
74
+ */
75
+ function sanitizeFilenameForHeader(filename) {
76
+ return filename.replace(/["\\\r\n]/g, '_');
77
+ }
78
+
60
79
  /**
61
80
  * Generate the download HTML page
62
81
  */
@@ -69,6 +88,7 @@ function generateDownloadPage(share, fileRecord) {
69
88
  : fileSize > 1024
70
89
  ? `${(fileSize / 1024).toFixed(1)} KB`
71
90
  : `${fileSize} B`;
91
+ const safeFilename = escapeHtml(fileRecord.filename);
72
92
 
73
93
  return `<!DOCTYPE html>
74
94
  <html lang="en">
@@ -146,7 +166,7 @@ function generateDownloadPage(share, fileRecord) {
146
166
  <div class="card">
147
167
  <div class="icon">🔐</div>
148
168
  <h1>Secure File Share</h1>
149
- <div class="filename">${fileRecord.filename}</div>
169
+ <div class="filename">${safeFilename}</div>
150
170
  <div class="meta">
151
171
  <span>📦 ${sizeStr}</span>
152
172
  <span>⏳ ${timeLeft}</span>
@@ -241,16 +261,20 @@ export class ShareServer {
241
261
  }
242
262
 
243
263
  /**
244
- * Stream a decrypted file from Telegram directly to the HTTP response
264
+ * Prepare download streams from Telegram — fetches and verifies
265
+ * the first chunk before returning, so the caller can decide
266
+ * whether to commit to a 200 response.
267
+ *
268
+ * Returns: { downloadStream, decryptStream, decompressStream }
245
269
  */
246
- async streamToResponse(fileRecord, res) {
270
+ async prepareDownloadStreams(fileRecord) {
247
271
  const chunks = this.db.getChunks(fileRecord.id);
248
272
  if (chunks.length === 0) throw new Error('No chunks found');
249
273
 
250
274
  // Pre-sort chunks by index so we download them in correct order
251
275
  chunks.sort((a, b) => a.chunk_index - b.chunk_index);
252
276
 
253
- // Get total size from first chunk's header
277
+ // Fetch and verify the first chunk eagerly
254
278
  const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
255
279
  const header = parseHeader(firstChunkData);
256
280
  let wasCompressed = header.compressed;
@@ -259,9 +283,7 @@ export class ShareServer {
259
283
  const decryptStream = this.encryptor.getDecryptStream();
260
284
  const decompressStream = this.compressor.getDecompressStream(wasCompressed);
261
285
 
262
- // We need a Readable stream that will lazily fetch chunks from Telegram
263
286
  const { Readable } = await import('stream');
264
- const { pipeline } = await import('stream/promises');
265
287
 
266
288
  const self = this;
267
289
  let currentChunkIndex = 0;
@@ -296,8 +318,7 @@ export class ShareServer {
296
318
  }
297
319
  });
298
320
 
299
- // Pipeline: Download from Telegram -> Decrypt -> Decompress -> HTTP Response
300
- await pipeline(downloadStream, decryptStream, decompressStream, res);
321
+ return { downloadStream, decryptStream, decompressStream };
301
322
  }
302
323
 
303
324
  /**
@@ -378,15 +399,33 @@ export class ShareServer {
378
399
  '.mp3': 'audio/mpeg'
379
400
  };
380
401
  const contentType = contentTypes[ext] || 'application/octet-stream';
402
+ const safeName = sanitizeFilenameForHeader(fileRecord.filename);
403
+
404
+ try {
405
+ // Verify first chunk can be fetched and decrypted BEFORE sending 200 OK.
406
+ // This prevents sending a truncated/corrupt file on Telegram or decrypt failure.
407
+ const { downloadStream, decryptStream, decompressStream } = await this.prepareDownloadStreams(fileRecord);
408
+
409
+ // First chunk verified — commit to the 200 response
410
+ res.writeHead(200, {
411
+ 'Content-Type': contentType,
412
+ 'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
413
+ 'Transfer-Encoding': 'chunked'
414
+ });
381
415
 
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);
416
+ const { pipeline } = await import('stream/promises');
417
+ await pipeline(downloadStream, decryptStream, decompressStream, res);
418
+ } catch (streamErr) {
419
+ console.error('Share stream error:', streamErr.message);
420
+ // Headers not yet sent — we can still return a proper error
421
+ if (!res.headersSent) {
422
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
423
+ res.end('Download failed — file could not be decrypted or fetched.');
424
+ } else {
425
+ // Headers already sent (shouldn't happen now, but safety net)
426
+ res.end();
427
+ }
428
+ }
390
429
 
391
430
  } catch (err) {
392
431
  console.error('Share server error:', err.message);