@nightowne/tas-cli 2.1.0 → 2.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.
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, HEADER_SIZE } from './utils/chunker.js';
12
12
  import { TelegramClient } from './telegram/client.js';
13
13
  import { FileIndex } from './db/index.js';
14
14
 
@@ -173,11 +173,12 @@ export async function processFile(filePath, options) {
173
173
 
174
174
  db.close();
175
175
 
176
- // Clean up temp dir
176
+ // Clean up temp dir (only if empty)
177
177
  try {
178
- fs.rmdirSync(tempDir);
178
+ const remaining = fs.readdirSync(tempDir);
179
+ if (remaining.length === 0) fs.rmdirSync(tempDir);
179
180
  } catch (e) {
180
- // Ignore if not empty
181
+ // Ignore cleanup errors
181
182
  }
182
183
 
183
184
  return {
@@ -209,91 +210,45 @@ export async function retrieveFile(fileRecord, options) {
209
210
  throw new Error('No chunk metadata found for this file');
210
211
  }
211
212
 
212
- // Prepare components
213
- const encryptor = new Encryptor(password);
214
- const decryptStream = encryptor.getDecryptStream();
215
-
216
- const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
217
- if (!fs.existsSync(tempDir)) {
218
- fs.mkdirSync(tempDir, { recursive: true });
219
- }
220
-
221
213
  // Connect to Telegram
222
214
  const client = new TelegramClient(dataDir);
223
215
  await client.initialize(config.botToken);
224
216
  client.setChatId(config.chatId);
225
217
 
226
- // Get total size from first chunk's header, or from DB
227
- const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
228
- const header = parseHeader(firstChunkData);
229
-
230
- // Total original uncompressed size
231
- let expectedOriginalSize = header.originalSize;
232
- let wasCompressed = header.compressed;
233
-
218
+ const encryptor = new Encryptor(password);
234
219
  const compressor = new Compressor();
235
- const decompressStream = compressor.getDecompressStream(wasCompressed);
236
-
237
- // We need a Readable stream that will lazily fetch chunks from Telegram
238
- // and push them into the decryption pipeline.
239
- const { Readable } = await import('stream');
240
-
241
- const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
242
- let downloadedBytes = 0;
243
-
244
- // Pre-sort chunks by index so we download them in correct order
245
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
246
220
 
247
- let currentChunkIndex = 0;
221
+ const { createDownloadPipeline } = await import('./utils/download-stream.js');
248
222
 
249
- // We already downloaded the first chunk to inspect its header, we shouldn't discard it.
250
- let preloadedFirstChunk = firstChunkData;
251
-
252
- const downloadStream = new Readable({
253
- async read() {
254
- try {
255
- if (currentChunkIndex >= chunks.length) {
256
- this.push(null); // End of stream
257
- return;
258
- }
259
-
260
- const chunk = chunks[currentChunkIndex];
261
- onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
262
-
263
- let data;
264
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
265
- data = preloadedFirstChunk;
266
- preloadedFirstChunk = null;
267
- } else {
268
- data = await client.downloadFile(chunk.file_telegram_id);
269
- }
270
-
271
- downloadedBytes += data.length;
272
- onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
273
-
274
- // Strip header before pushing
275
- const payload = data.subarray(HEADER_SIZE);
276
- this.push(payload);
277
-
278
- currentChunkIndex++;
279
- } catch (err) {
280
- this.destroy(err);
281
- }
223
+ const { readable } = await createDownloadPipeline({
224
+ client,
225
+ chunks,
226
+ encryptor,
227
+ compressor,
228
+ onChunkDownloaded({ chunkIndex, totalChunks, bytesDownloaded, totalBytes }) {
229
+ onProgress?.(`Downloading chunk ${chunkIndex + 1}/${totalChunks}...`);
230
+ onByteProgress?.({ downloaded: bytesDownloaded, total: totalBytes, chunk: chunkIndex + 1, totalChunks });
282
231
  }
283
232
  });
284
233
 
285
234
  const writeStream = fs.createWriteStream(outputPath);
286
- const { pipeline } = await import('stream/promises');
287
235
 
288
236
  onProgress?.('Decrypting, decompressing, and writing file...');
289
237
 
290
- // Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
291
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
238
+ await pipeline(readable, writeStream);
292
239
 
293
240
  const finalStats = fs.statSync(outputPath);
294
241
 
242
+ // Verify file integrity by comparing hash
243
+ onProgress?.('Verifying file integrity...');
244
+ const downloadedHash = await hashFile(outputPath);
245
+ if (fileRecord.hash && downloadedHash !== fileRecord.hash) {
246
+ throw new Error(`Integrity check failed: expected hash ${fileRecord.hash.substring(0, 12)}..., got ${downloadedHash.substring(0, 12)}...`);
247
+ }
248
+
295
249
  return {
296
250
  path: outputPath,
297
- size: finalStats.size
251
+ size: finalStats.size,
252
+ verified: true
298
253
  };
299
254
  }
@@ -7,11 +7,12 @@
7
7
  import http from 'http';
8
8
  import crypto from 'crypto';
9
9
  import path from 'path';
10
+ import { pipeline } from 'stream/promises';
10
11
  import { FileIndex } from '../db/index.js';
11
12
  import { TelegramClient } from '../telegram/client.js';
12
13
  import { Encryptor } from '../crypto/encryption.js';
13
14
  import { Compressor } from '../utils/compression.js';
14
- import { parseHeader, HEADER_SIZE } from '../utils/chunker.js';
15
+ import { createDownloadPipeline } from '../utils/download-stream.js';
15
16
 
16
17
  /**
17
18
  * Generate a secure random share token
@@ -57,6 +58,25 @@ function formatTimeLeft(expiresAt) {
57
58
  return `${minutes}m`;
58
59
  }
59
60
 
61
+ /**
62
+ * Escape HTML special characters to prevent XSS
63
+ */
64
+ function escapeHtml(str) {
65
+ return String(str)
66
+ .replace(/&/g, '&')
67
+ .replace(/</g, '&lt;')
68
+ .replace(/>/g, '&gt;')
69
+ .replace(/"/g, '&quot;')
70
+ .replace(/'/g, '&#x27;');
71
+ }
72
+
73
+ /**
74
+ * Sanitize filename for Content-Disposition header (RFC 6266)
75
+ */
76
+ function sanitizeFilenameForHeader(filename) {
77
+ return filename.replace(/["\\\r\n]/g, '_');
78
+ }
79
+
60
80
  /**
61
81
  * Generate the download HTML page
62
82
  */
@@ -69,6 +89,7 @@ function generateDownloadPage(share, fileRecord) {
69
89
  : fileSize > 1024
70
90
  ? `${(fileSize / 1024).toFixed(1)} KB`
71
91
  : `${fileSize} B`;
92
+ const safeFilename = escapeHtml(fileRecord.filename);
72
93
 
73
94
  return `<!DOCTYPE html>
74
95
  <html lang="en">
@@ -146,7 +167,7 @@ function generateDownloadPage(share, fileRecord) {
146
167
  <div class="card">
147
168
  <div class="icon">🔐</div>
148
169
  <h1>Secure File Share</h1>
149
- <div class="filename">${fileRecord.filename}</div>
170
+ <div class="filename">${safeFilename}</div>
150
171
  <div class="meta">
151
172
  <span>📦 ${sizeStr}</span>
152
173
  <span>⏳ ${timeLeft}</span>
@@ -219,7 +240,7 @@ export class ShareServer {
219
240
  this.password = options.password;
220
241
  this.config = options.config;
221
242
  this.port = options.port || 3000;
222
- this.host = options.host || '0.0.0.0';
243
+ this.host = options.host || '127.0.0.1';
223
244
 
224
245
  this.db = null;
225
246
  this.client = null;
@@ -245,59 +266,15 @@ export class ShareServer {
245
266
  */
246
267
  async streamToResponse(fileRecord, res) {
247
268
  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
- }
269
+
270
+ const { readable } = await createDownloadPipeline({
271
+ client: this.client,
272
+ chunks,
273
+ encryptor: this.encryptor,
274
+ compressor: this.compressor
297
275
  });
298
276
 
299
- // Pipeline: Download from Telegram -> Decrypt -> Decompress -> HTTP Response
300
- await pipeline(downloadStream, decryptStream, decompressStream, res);
277
+ await pipeline(readable, res);
301
278
  }
302
279
 
303
280
  /**
@@ -378,11 +355,12 @@ export class ShareServer {
378
355
  '.mp3': 'audio/mpeg'
379
356
  };
380
357
  const contentType = contentTypes[ext] || 'application/octet-stream';
358
+ const safeName = sanitizeFilenameForHeader(fileRecord.filename);
381
359
 
382
360
  res.writeHead(200, {
383
361
  'Content-Type': contentType,
384
- 'Content-Disposition': `attachment; filename="${fileRecord.filename}"`,
385
- 'Content-Length': fileRecord.original_size
362
+ 'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
363
+ 'Transfer-Encoding': 'chunked'
386
364
  });
387
365
 
388
366
  // Download the file from Telegram, decrypt, decompress and stream directly to 'res'
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Telegram Bot client wrapper
3
3
  * Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
4
+ * Includes exponential backoff retry and rate limiting for production reliability.
4
5
  */
5
6
 
6
7
  import TelegramBot from 'node-telegram-bot-api';
@@ -8,11 +9,46 @@ import fs from 'fs';
8
9
  import path from 'path';
9
10
  import { pipeline } from 'stream/promises';
10
11
 
12
+ const MAX_RETRIES = 5;
13
+ const BASE_DELAY_MS = 1000;
14
+ const MAX_DELAY_MS = 60000;
15
+
16
+ /**
17
+ * Retry an async function with exponential backoff + jitter.
18
+ * Handles Telegram 429 (rate limit) errors by respecting retry_after.
19
+ */
20
+ async function withRetry(fn, label = 'operation', retries = MAX_RETRIES) {
21
+ for (let attempt = 0; attempt <= retries; attempt++) {
22
+ try {
23
+ return await fn();
24
+ } catch (err) {
25
+ const isRateLimit = err?.response?.statusCode === 429 || err?.message?.includes('429');
26
+ const isTransient = err?.code === 'ETIMEOUT' || err?.code === 'ECONNRESET' ||
27
+ err?.code === 'ENOTFOUND' || err?.message?.includes('ETIMEDOUT');
28
+
29
+ if (attempt >= retries) throw err;
30
+ if (!isRateLimit && !isTransient) throw err;
31
+
32
+ let delay;
33
+ if (isRateLimit && err?.response?.body?.parameters?.retry_after) {
34
+ delay = err.response.body.parameters.retry_after * 1000 + 500;
35
+ } else {
36
+ delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000, MAX_DELAY_MS);
37
+ }
38
+
39
+ const sec = (delay / 1000).toFixed(1);
40
+ console.log(`⏳ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${sec}s...`);
41
+ await new Promise(r => setTimeout(r, delay));
42
+ }
43
+ }
44
+ }
45
+
11
46
  export class TelegramClient {
12
47
  constructor(dataDir) {
13
48
  this.dataDir = dataDir;
14
49
  this.bot = null;
15
50
  this.chatId = null;
51
+ this._lastSendTime = 0;
16
52
  }
17
53
 
18
54
  /**
@@ -76,9 +112,22 @@ export class TelegramClient {
76
112
  });
77
113
  }
78
114
 
115
+ /**
116
+ * Rate-limit: ensure at least 1s between sends to same chat (Telegram limit)
117
+ */
118
+ async _rateLimit() {
119
+ const now = Date.now();
120
+ const elapsed = now - this._lastSendTime;
121
+ if (elapsed < 1000) {
122
+ await new Promise(r => setTimeout(r, 1000 - elapsed));
123
+ }
124
+ this._lastSendTime = Date.now();
125
+ }
126
+
79
127
  /**
80
128
  * Send a file to the storage chat
81
129
  * Telegram supports up to 2GB for documents!
130
+ * Includes automatic retry with exponential backoff.
82
131
  */
83
132
  async sendFile(filePath, caption = '', options = {}) {
84
133
  if (!this.chatId) {
@@ -89,9 +138,12 @@ export class TelegramClient {
89
138
  throw new Error(`File not found: ${filePath}`);
90
139
  }
91
140
 
92
- try {
141
+ const filename = path.basename(filePath);
142
+
143
+ return withRetry(async () => {
144
+ await this._rateLimit();
145
+
93
146
  let fileStream = fs.createReadStream(filePath);
94
- const filename = path.basename(filePath);
95
147
 
96
148
  if (options.limitRate) {
97
149
  const { Throttle } = await import('../utils/throttle.js');
@@ -110,28 +162,24 @@ export class TelegramClient {
110
162
  fileId: message.document.file_id,
111
163
  timestamp: message.date
112
164
  };
113
- } catch (err) {
114
- throw new Error(`Failed to upload to Telegram: ${err.message}`);
115
- }
165
+ }, `Upload ${filename}`);
116
166
  }
117
167
 
118
168
  /**
119
169
  * Download a file from Telegram (In-Memory buffer)
170
+ * Includes automatic retry with exponential backoff.
120
171
  */
121
172
  async downloadFile(fileId) {
122
- // Get file path from Telegram servers
123
- const file = await this.bot.getFile(fileId);
124
-
125
- // Download the file
126
- const fileStream = await this.bot.getFileStream(fileId);
173
+ return withRetry(async () => {
174
+ const fileStream = await this.bot.getFileStream(fileId);
127
175
 
128
- // Collect chunks into buffer
129
- const chunks = [];
130
- for await (const chunk of fileStream) {
131
- chunks.push(chunk);
132
- }
176
+ const chunks = [];
177
+ for await (const chunk of fileStream) {
178
+ chunks.push(chunk);
179
+ }
133
180
 
134
- return Buffer.concat(chunks);
181
+ return Buffer.concat(chunks);
182
+ }, `Download ${fileId.substring(0, 12)}...`);
135
183
  }
136
184
 
137
185
  /**
@@ -14,7 +14,7 @@ export const LOGO = `
14
14
  `;
15
15
 
16
16
  export const TAGLINE = 'Telegram as Storage';
17
- export const VERSION = '1.0.0';
17
+ export const VERSION = '2.4.0';
18
18
 
19
19
  /**
20
20
  * Print the TAS banner
@@ -57,10 +57,10 @@ export function warn(msg) {
57
57
  * Format file size
58
58
  */
59
59
  export function formatSize(bytes) {
60
- if (bytes === 0) return '0 B';
60
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
61
61
  const k = 1024;
62
62
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
63
- const i = Math.floor(Math.log(bytes) / Math.log(k));
63
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
64
64
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
65
65
  }
66
66
 
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * File chunking utilities for large files
3
- * WhatsApp document limit is ~2GB, we use 1.9GB chunks to be safe
3
+ * Telegram Bot API limit is 50 MB for bot uploads (sendDocument).
4
+ * We use 49 MB to leave room for the 64-byte WAS1 header.
5
+ * See: https://core.telegram.org/bots/api#senddocument
4
6
  */
5
7
 
6
- const MAX_CHUNK_SIZE = 1.9 * 1024 * 1024 * 1024; // 1.9 GB
8
+ const MAX_CHUNK_SIZE = 49 * 1024 * 1024; // 49 MB — Telegram Bot API safe limit
7
9
 
8
10
  export class Chunker {
9
11
  /**
@@ -39,14 +39,13 @@ export async function getPassword(passwordOption, allowCache = true) {
39
39
  }
40
40
 
41
41
  /**
42
- * Verify password against config
42
+ * Verify password against config (supports both legacy and new hash formats)
43
43
  * @param {string} password - Password to verify
44
44
  * @param {Object} config - Config object with passwordHash
45
45
  * @returns {boolean}
46
46
  */
47
47
  export function verifyPassword(password, config) {
48
- const encryptor = new Encryptor(password);
49
- return encryptor.getPasswordHash() === config.passwordHash;
48
+ return Encryptor.verifyPasswordHash(password, config.passwordHash);
50
49
  }
51
50
 
52
51
  /**
@@ -62,11 +61,13 @@ export function validateConfig(config) {
62
61
  return { valid: false, errors };
63
62
  }
64
63
 
65
- if (!config.botToken || typeof config.botToken !== 'string') {
66
- errors.push('Missing or invalid botToken');
64
+ // v2: encrypted token, v1: plaintext token
65
+ const hasToken = config.encryptedBotToken || config.botToken;
66
+ if (!hasToken) {
67
+ errors.push('Missing bot token (botToken or encryptedBotToken)');
67
68
  }
68
69
 
69
- if (!config.botToken?.includes(':')) {
70
+ if (config.botToken && !config.botToken.includes(':')) {
70
71
  errors.push('Invalid bot token format (should contain :)');
71
72
  }
72
73
 
@@ -84,6 +85,29 @@ export function validateConfig(config) {
84
85
  };
85
86
  }
86
87
 
88
+ /**
89
+ * Decrypt bot token from config using password
90
+ * Supports both v1 (plaintext) and v2 (encrypted) configs
91
+ * @param {Object} config - Config object
92
+ * @param {string} password - User's password
93
+ * @returns {string} - Decrypted bot token
94
+ */
95
+ export function decryptBotToken(config, password) {
96
+ // v1: plaintext token (backward compatibility)
97
+ if (config.botToken) {
98
+ return config.botToken;
99
+ }
100
+
101
+ // v2: encrypted token
102
+ if (config.encryptedBotToken) {
103
+ const encryptor = new Encryptor(password);
104
+ const encryptedBuffer = Buffer.from(config.encryptedBotToken, 'base64');
105
+ return encryptor.decrypt(encryptedBuffer).toString('utf-8');
106
+ }
107
+
108
+ throw new Error('No bot token found in config');
109
+ }
110
+
87
111
  /**
88
112
  * Load and validate config
89
113
  * @param {string} dataDir - Data directory path
@@ -143,3 +167,17 @@ export async function getAndVerifyPassword(passwordOption, dataDir) {
143
167
 
144
168
  return password;
145
169
  }
170
+
171
+ /**
172
+ * Resolve config by decrypting bot token if needed.
173
+ * Returns a config object with a guaranteed plaintext `botToken` field.
174
+ * @param {Object} config - Raw config from disk
175
+ * @param {string} password - Verified password
176
+ * @returns {Object} - Config with decrypted botToken
177
+ */
178
+ export function resolveConfig(config, password) {
179
+ return {
180
+ ...config,
181
+ botToken: decryptBotToken(config, password)
182
+ };
183
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Shared download pipeline — Telegram → Decrypt → Decompress
3
+ *
4
+ * Eliminates the triplicated download-stream pattern across
5
+ * index.js, share/server.js, and fuse/mount.js.
6
+ */
7
+
8
+ import { Readable } from 'stream';
9
+ import { pipeline } from 'stream/promises';
10
+ import { parseHeader, HEADER_SIZE } from './chunker.js';
11
+
12
+ /**
13
+ * Create the download pipeline streams for a file stored in Telegram.
14
+ *
15
+ * @param {object} options
16
+ * @param {object} options.client – TelegramClient instance (initialized, with chatId set)
17
+ * @param {Array} options.chunks – Chunk records from DB, each with { chunk_index, file_telegram_id, size }
18
+ * @param {object} options.encryptor – Encryptor instance
19
+ * @param {object} options.compressor – Compressor instance
20
+ * @param {function} [options.onChunkDownloaded] – Optional callback({ chunkIndex, totalChunks, bytesDownloaded, totalBytes })
21
+ * @returns {Promise<{ readable: Readable, header: object }>}
22
+ * readable: a stream of decrypted (and decompressed) file content
23
+ * header: parsed WAS1 header from the first chunk
24
+ */
25
+ export async function createDownloadPipeline({ client, chunks, encryptor, compressor, onChunkDownloaded }) {
26
+ if (chunks.length === 0) throw new Error('No chunks found');
27
+
28
+ // Sort by chunk_index
29
+ const sortedChunks = [...chunks].sort((a, b) => a.chunk_index - b.chunk_index);
30
+
31
+ // Download the first chunk to inspect the header
32
+ const firstChunkData = await client.downloadFile(sortedChunks[0].file_telegram_id);
33
+ const header = parseHeader(firstChunkData);
34
+
35
+ const decryptStream = encryptor.getDecryptStream();
36
+ const decompressStream = compressor.getDecompressStream(header.compressed);
37
+
38
+ const totalBytes = sortedChunks.reduce((acc, c) => acc + (c.size || 0), 0);
39
+ let bytesDownloaded = 0;
40
+ let currentIndex = 0;
41
+ let preloadedFirst = firstChunkData;
42
+
43
+ const telegramStream = new Readable({
44
+ async read() {
45
+ try {
46
+ if (currentIndex >= sortedChunks.length) {
47
+ this.push(null);
48
+ return;
49
+ }
50
+
51
+ let data;
52
+ if (currentIndex === 0 && preloadedFirst) {
53
+ data = preloadedFirst;
54
+ preloadedFirst = null;
55
+ } else {
56
+ data = await client.downloadFile(sortedChunks[currentIndex].file_telegram_id);
57
+ }
58
+
59
+ bytesDownloaded += data.length;
60
+ onChunkDownloaded?.({
61
+ chunkIndex: currentIndex,
62
+ totalChunks: sortedChunks.length,
63
+ bytesDownloaded,
64
+ totalBytes
65
+ });
66
+
67
+ // Strip the WAS1 header before pushing into the decrypt pipeline
68
+ this.push(data.subarray(HEADER_SIZE));
69
+ currentIndex++;
70
+ } catch (err) {
71
+ this.destroy(err);
72
+ }
73
+ }
74
+ });
75
+
76
+ // Wire the internal pipeline: telegram → decrypt → decompress
77
+ // We use a PassThrough as the readable end so callers can pipe/pipeline it freely.
78
+ const { PassThrough } = await import('stream');
79
+ const output = new PassThrough();
80
+
81
+ // Run the internal pipeline in the background; errors propagate through the output stream.
82
+ pipeline(telegramStream, decryptStream, decompressStream, output).catch((err) => {
83
+ if (!output.destroyed) output.destroy(err);
84
+ });
85
+
86
+ return { readable: output, header };
87
+ }
@@ -76,10 +76,10 @@ export class ProgressBar {
76
76
  * Format bytes to human readable
77
77
  */
78
78
  formatBytes(bytes) {
79
- if (bytes === 0) return '0 B';
79
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
80
80
  const k = 1024;
81
- const sizes = ['B', 'KB', 'MB', 'GB'];
82
- const i = Math.floor(Math.log(bytes) / Math.log(k));
81
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
82
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
83
83
  return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
84
84
  }
85
85