@nightowne/tas-cli 2.3.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/README.md CHANGED
@@ -198,11 +198,10 @@ TAS implements **zero-knowledge encryption** — we can't read your data, Telegr
198
198
  | **IV** | 12 bytes, cryptographically random | Unique per file — no pattern analysis |
199
199
  | **Auth Tag** | 16 bytes GCM authentication | Tamper detection — any bit flip = rejected |
200
200
  | **Bot Token** | Encrypted at rest (AES-256-GCM) | Even your config file is protected |
201
- | **Password Hash** | PBKDF2-based verification | Your password hash is computationally expensive to crack |
201
+ | **Password Hash** | Timing-safe PBKDF2 verification | Resistant to timing side-channel attacks |
202
+ | **Config Permissions** | `chmod 600` on config.json | Other users on your system can't read your credentials |
202
203
  | **Integrity** | SHA-256 verified on every download | Bit-perfect downloads, guaranteed |
203
- | **Share Server** | XSS-safe, RFC 6266 headers | Hardened against injection attacks |
204
-
205
- For the full threat model, cipher rationale, and file format spec, see **[docs/security.md](docs/security.md)**.
204
+ | **Share Server** | Localhost-only, XSS-safe, RFC 6266 | Binds to 127.0.0.1 by default — your LAN doesn't see it |
206
205
 
207
206
  ### What Telegram Sees
208
207
 
@@ -224,7 +223,6 @@ Built like professional backup tools (inspired by restic, rclone, borg):
224
223
  | **Rate Limiting** | Built-in 1 msg/sec limiter — never hits Telegram's rate limits |
225
224
  | **Integrity Verification** | SHA-256 hash check after every single download |
226
225
  | **Resume Uploads** | Interrupted? Run `tas resume` to pick up where you left off |
227
- | **Atomic Transactions** | Upload pipeline uses SQLite transactions — pipeline failure = clean rollback, zero orphaned DB rows |
228
226
  | **Graceful Shutdown** | SIGINT/SIGTERM handled cleanly — zero data corruption risk |
229
227
  | **Self-Diagnostics** | `tas doctor` validates your entire setup in seconds |
230
228
 
@@ -290,27 +288,28 @@ tas tag list [tag] # List tags or files with a specific tag
290
288
 
291
289
  ```
292
290
  src/
293
- ├── cli.js # Commander-based CLI — all commands
294
- ├── index.js # Streaming upload/download pipeline
291
+ ├── cli.js # Commander-based CLI — all commands
292
+ ├── index.js # Streaming upload/download pipeline
295
293
  ├── crypto/
296
- │ └── encryption.js # AES-256-GCM + PBKDF2-SHA512 key derivation
294
+ │ └── encryption.js # AES-256-GCM + PBKDF2-SHA512 key derivation
297
295
  ├── db/
298
- │ └── index.js # SQLite index (files, chunks, tags, shares, sync)
296
+ │ └── index.js # SQLite index (files, chunks, tags, shares, sync)
299
297
  ├── telegram/
300
- │ └── client.js # Bot API wrapper — retry, rate-limit, streaming
298
+ │ └── client.js # Bot API wrapper — retry, rate-limit, streaming
301
299
  ├── fuse/
302
- │ └── mount.js # FUSE filesystem — mount Telegram as a folder
300
+ │ └── mount.js # FUSE filesystem — mount Telegram as a folder
303
301
  ├── share/
304
- │ └── server.js # HTTP server — expiring download links
302
+ │ └── server.js # HTTP server — expiring download links
305
303
  ├── sync/
306
- │ └── sync.js # Folder watcher — Dropbox-style auto-sync
304
+ │ └── sync.js # Folder watcher — Dropbox-style auto-sync
307
305
  └── utils/
308
- ├── compression.js # Smart gzip (skips already-compressed formats)
309
- ├── chunker.js # 49MB chunking with custom WAS1 file headers
310
- ├── progress.js # Terminal progress bar with speed + ETA
311
- ├── throttle.js # Bandwidth limiter (stream transform)
312
- ├── branding.js # ASCII art + version (auto-synced from package.json)
313
- └── cli-helpers.js # Password management + config resolution
306
+ ├── download-stream.js # Shared Telegram→Decrypt→Decompress pipeline
307
+ ├── compression.js # Smart gzip (skips already-compressed formats)
308
+ ├── chunker.js # 49MB chunking with custom WAS1 file headers
309
+ ├── progress.js # Terminal progress bar with speed + ETA
310
+ ├── throttle.js # Bandwidth limiter (stream transform)
311
+ ├── branding.js # ASCII art + formatting
312
+ └── cli-helpers.js # Password management + config resolution
314
313
  ```
315
314
 
316
315
  **Tech stack:** Node.js · better-sqlite3 · node-telegram-bot-api · fuse-native · Commander · Chalk · Ora · Inquirer
@@ -334,27 +333,13 @@ src/
334
333
  ```bash
335
334
  git clone https://github.com/ixchio/tas
336
335
  cd tas && npm install
337
- npm test # 83 tests across 16 suites, all passing
336
+ npm test # 71 tests, all passing
338
337
  ```
339
338
 
340
- For the full developer guide (architecture, testing, conventions, gotchas), see **[docs/development.md](docs/development.md)**.
341
-
342
339
  PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
343
340
 
344
341
  <br>
345
342
 
346
- ## 📚 Documentation
347
-
348
- | Doc | What's in it |
349
- |-----|-------------|
350
- | [docs/development.md](docs/development.md) | Developer onboarding — project structure, how to add commands, testing, conventions |
351
- | [docs/security.md](docs/security.md) | Threat model — what TAS protects against, cipher details, file format specs |
352
- | [CHANGELOG.md](CHANGELOG.md) | Version history with all changes documented |
353
- | [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute — fork, branch, PR |
354
- | [SECURITY.md](SECURITY.md) | Security vulnerability reporting |
355
-
356
- <br>
357
-
358
343
  ## 🌟 Contributing
359
344
 
360
345
  TAS is open source and we love contributions:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Telegram as Storage - Automated encrypted cloud backup. Free, encrypted, scriptable. Mount as folder or use with cron/Docker.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -56,4 +56,4 @@
56
56
  "engines": {
57
57
  "node": ">=18.0.0"
58
58
  }
59
- }
59
+ }
package/src/cli.js CHANGED
@@ -133,6 +133,8 @@ program
133
133
  createdAt: new Date().toISOString(),
134
134
  configVersion: 2
135
135
  }, null, 2));
136
+ // Restrict config file permissions — contains encrypted token and password hash
137
+ try { fs.chmodSync(configPath, 0o600); } catch { /* ignore on Windows */ }
136
138
 
137
139
  // Initialize database
138
140
  spinner.start('Initializing local index...');
@@ -28,12 +28,18 @@ export class Encryptor {
28
28
 
29
29
  /**
30
30
  * Check password against a stored hash (supports both legacy SHA-256 and new PBKDF2 formats)
31
+ * Uses timing-safe comparison to prevent side-channel attacks.
31
32
  */
32
33
  static verifyPasswordHash(password, storedHash) {
33
34
  const encryptor = new Encryptor(password);
34
35
 
36
+ const computedHash = encryptor.getPasswordHash();
37
+ const computedBuf = Buffer.from(computedHash, 'utf-8');
38
+ const storedBuf = Buffer.from(storedHash, 'utf-8');
39
+
35
40
  // Try new PBKDF2-based verification first
36
- if (encryptor.getPasswordHash() === storedHash) {
41
+ if (computedBuf.length === storedBuf.length &&
42
+ crypto.timingSafeEqual(computedBuf, storedBuf)) {
37
43
  return true;
38
44
  }
39
45
 
@@ -41,7 +47,14 @@ export class Encryptor {
41
47
  const legacyHash = crypto.createHash('sha256')
42
48
  .update(password + 'was-verify')
43
49
  .digest('hex');
44
- return legacyHash === storedHash;
50
+ const legacyBuf = Buffer.from(legacyHash, 'utf-8');
51
+
52
+ if (legacyBuf.length === storedBuf.length &&
53
+ crypto.timingSafeEqual(legacyBuf, storedBuf)) {
54
+ return true;
55
+ }
56
+
57
+ return false;
45
58
  }
46
59
 
47
60
  /**
package/src/fuse/mount.js CHANGED
@@ -7,8 +7,7 @@
7
7
 
8
8
  import path from 'path';
9
9
  import fs from 'fs';
10
- import crypto from 'crypto';
11
- import { processFile } from '../index.js';
10
+ import { pipeline } from 'stream/promises';
12
11
 
13
12
  let Fuse;
14
13
  try {
@@ -20,8 +19,11 @@ import { TelegramClient } from '../telegram/client.js';
20
19
  import { Encryptor } from '../crypto/encryption.js';
21
20
  import { Compressor } from '../utils/compression.js';
22
21
  import { FileIndex } from '../db/index.js';
23
- import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
22
+ import { createHeader } from '../utils/chunker.js';
23
+ import { createDownloadPipeline } from '../utils/download-stream.js';
24
24
 
25
+ // File cache for performance (avoid re-downloading)
26
+ const fileCache = new Map();
25
27
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
26
28
  const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
27
29
 
@@ -52,9 +54,6 @@ export class TelegramFS {
52
54
 
53
55
  // Pending writes buffer
54
56
  this.writeBuffers = new Map();
55
-
56
- // Instance-level file cache to prevent cross-talk
57
- this.fileCache = new Map();
58
57
  }
59
58
 
60
59
  async initialize() {
@@ -90,7 +89,7 @@ export class TelegramFS {
90
89
  mtime: new Date(),
91
90
  atime: new Date(),
92
91
  ctime: new Date(),
93
- size: wb.size,
92
+ size: wb.data.length,
94
93
  mode: 0o100644, // regular file
95
94
  uid: process.getuid?.() || 0,
96
95
  gid: process.getgid?.() || 0
@@ -124,14 +123,9 @@ export class TelegramFS {
124
123
  }
125
124
 
126
125
  const files = this.db.listAll();
127
- const nameSet = new Set(files.map(f => f.filename));
126
+ const names = files.map(f => f.filename);
128
127
 
129
- // Include files currently being written (not yet released/uploaded)
130
- for (const name of this.writeBuffers.keys()) {
131
- nameSet.add(name);
132
- }
133
-
134
- return cb(0, [...nameSet]);
128
+ return cb(0, names);
135
129
  }
136
130
 
137
131
  /**
@@ -164,8 +158,9 @@ export class TelegramFS {
164
158
  // Check write buffers first
165
159
  const wb = this.writeBuffers.get(filename);
166
160
  if (wb) {
167
- const bytesRead = fs.readSync(wb.fd, buffer, 0, length, position);
168
- return cb(bytesRead);
161
+ const slice = wb.data.subarray(position, position + length);
162
+ slice.copy(buffer);
163
+ return cb(slice.length);
169
164
  }
170
165
 
171
166
  // Check cache first
@@ -190,55 +185,34 @@ export class TelegramFS {
190
185
  }
191
186
 
192
187
  /**
193
- * Helper to get or create a disk-buffered write structure
188
+ * Write to a file (buffers until release)
194
189
  */
195
- getOrCreateWriteBuffer(filename) {
196
- if (this.writeBuffers.has(filename)) {
197
- return this.writeBuffers.get(filename);
198
- }
190
+ write(filepath, fd, buffer, length, position, cb) {
191
+ const filename = path.basename(filepath);
199
192
 
200
- const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
201
- if (!fs.existsSync(fuseTmpDir)) {
202
- fs.mkdirSync(fuseTmpDir, { recursive: true });
193
+ // Get or create write buffer
194
+ if (!this.writeBuffers.has(filename)) {
195
+ this.writeBuffers.set(filename, {
196
+ data: Buffer.alloc(0),
197
+ modified: true
198
+ });
203
199
  }
204
200
 
205
- const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
206
- const fdDisk = fs.openSync(tmpPath, 'w+');
201
+ const wb = this.writeBuffers.get(filename);
207
202
 
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;
203
+ // Expand buffer if needed
204
+ const newSize = Math.max(wb.data.length, position + length);
205
+ if (newSize > wb.data.length) {
206
+ const newBuf = Buffer.alloc(newSize);
207
+ wb.data.copy(newBuf);
208
+ wb.data = newBuf;
215
209
  }
216
210
 
217
- const wb = {
218
- tmpPath,
219
- fd: fdDisk,
220
- size,
221
- modified: true
222
- };
223
- this.writeBuffers.set(filename, wb);
224
- return wb;
225
- }
211
+ // Copy incoming data
212
+ buffer.copy(wb.data, position, 0, length);
213
+ wb.modified = true;
226
214
 
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
- }
215
+ return cb(length);
242
216
  }
243
217
 
244
218
  /**
@@ -249,19 +223,9 @@ export class TelegramFS {
249
223
 
250
224
  console.log(`[FUSE] Creating file: ${filename}`);
251
225
 
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
-
226
+ // Initialize empty write buffer
261
227
  this.writeBuffers.set(filename, {
262
- tmpPath,
263
- fd: fdDisk,
264
- size: 0,
228
+ data: Buffer.alloc(0),
265
229
  modified: true,
266
230
  isNew: true
267
231
  });
@@ -283,26 +247,15 @@ export class TelegramFS {
283
247
  const filename = path.basename(filepath);
284
248
 
285
249
  const wb = this.writeBuffers.get(filename);
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);
250
+ if (!wb || !wb.modified) {
294
251
  return cb(0);
295
252
  }
296
253
 
297
254
  try {
298
- // Close fd so we can read and upload cleanly
299
- try { fs.closeSync(wb.fd); } catch (e) {}
255
+ // Upload to Telegram
256
+ await this.uploadFile(filename, wb.data);
300
257
 
301
- // Upload to Telegram from the temp path
302
- await this.uploadFile(filename, wb.tmpPath);
303
-
304
- // Clean up temp file
305
- try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
258
+ // Clear write buffer
306
259
  this.writeBuffers.delete(filename);
307
260
 
308
261
  // Invalidate cache
@@ -310,9 +263,7 @@ export class TelegramFS {
310
263
 
311
264
  return cb(0);
312
265
  } catch (err) {
313
- console.error('[FUSE] Release error:', err.message);
314
- try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
315
- this.writeBuffers.delete(filename);
266
+ console.error('Release error:', err.message);
316
267
  return cb(Fuse.EIO);
317
268
  }
318
269
  }
@@ -329,7 +280,7 @@ export class TelegramFS {
329
280
  }
330
281
 
331
282
  try {
332
- // Delete from Telegram
283
+ // Delete from Telegram (optional - could just remove from index)
333
284
  const chunks = this.db.getChunks(file.id);
334
285
  for (const chunk of chunks) {
335
286
  await this.client.deleteMessage(chunk.message_id);
@@ -343,7 +294,7 @@ export class TelegramFS {
343
294
 
344
295
  return cb(0);
345
296
  } catch (err) {
346
- console.error('[FUSE] Unlink error:', err.message);
297
+ console.error('Unlink error:', err.message);
347
298
  return cb(Fuse.EIO);
348
299
  }
349
300
  }
@@ -365,10 +316,10 @@ export class TelegramFS {
365
316
  .run(newName, file.id);
366
317
 
367
318
  // Update cache key
368
- const cached = this.fileCache.get(oldName);
319
+ const cached = fileCache.get(oldName);
369
320
  if (cached) {
370
- this.fileCache.delete(oldName);
371
- this.fileCache.set(newName, cached);
321
+ fileCache.delete(oldName);
322
+ fileCache.set(newName, cached);
372
323
  }
373
324
 
374
325
  return cb(0);
@@ -379,16 +330,35 @@ export class TelegramFS {
379
330
  */
380
331
  truncate(filepath, size, cb) {
381
332
  const filename = path.basename(filepath);
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);
333
+
334
+ // Get or load into write buffer
335
+ if (!this.writeBuffers.has(filename)) {
336
+ const cachedPath = this.getCached(filename);
337
+ if (cachedPath) {
338
+ this.writeBuffers.set(filename, {
339
+ data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
340
+ modified: true
341
+ });
342
+ } else {
343
+ this.writeBuffers.set(filename, {
344
+ data: Buffer.alloc(0),
345
+ modified: true
346
+ });
347
+ }
391
348
  }
349
+
350
+ const wb = this.writeBuffers.get(filename);
351
+
352
+ if (size < wb.data.length) {
353
+ wb.data = wb.data.subarray(0, size);
354
+ } else if (size > wb.data.length) {
355
+ const newBuf = Buffer.alloc(size);
356
+ wb.data.copy(newBuf);
357
+ wb.data = newBuf;
358
+ }
359
+
360
+ wb.modified = true;
361
+ return cb(0);
392
362
  }
393
363
 
394
364
  // ============== Helper Methods ==============
@@ -413,56 +383,18 @@ export class TelegramFS {
413
383
  }
414
384
 
415
385
  const chunks = this.db.getChunks(file.id);
416
- if (chunks.length === 0) throw new Error('No chunks found');
417
-
418
- // Pre-sort chunks
419
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
420
-
421
- const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
422
- const header = parseHeader(firstChunkData);
423
- let wasCompressed = header.compressed;
424
-
425
- const decryptStream = this.encryptor.getDecryptStream();
426
- const decompressStream = this.compressor.getDecompressStream(wasCompressed);
427
-
428
- const { Readable } = await import('stream');
429
- const { pipeline } = await import('stream/promises');
430
-
431
- const self = this;
432
- let currentChunkIndex = 0;
433
- let preloadedFirstChunk = firstChunkData;
434
-
435
- const downloadStream = new Readable({
436
- async read() {
437
- try {
438
- if (currentChunkIndex >= chunks.length) {
439
- this.push(null);
440
- return;
441
- }
442
-
443
- const chunk = chunks[currentChunkIndex];
444
- let data;
445
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
446
- data = preloadedFirstChunk;
447
- preloadedFirstChunk = null;
448
- } else {
449
- data = await self.client.downloadFile(chunk.file_telegram_id);
450
- }
451
-
452
- const payload = data.subarray(HEADER_SIZE);
453
- this.push(payload);
454
- currentChunkIndex++;
455
- } catch (err) {
456
- this.destroy(err);
457
- }
458
- }
386
+
387
+ const { readable } = await createDownloadPipeline({
388
+ client: this.client,
389
+ chunks,
390
+ encryptor: this.encryptor,
391
+ compressor: this.compressor
459
392
  });
460
393
 
461
394
  const tmpOutputPath = outputPath + '.tmp';
462
395
  const writeStream = fs.createWriteStream(tmpOutputPath);
463
396
 
464
- // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
465
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
397
+ await pipeline(readable, writeStream);
466
398
 
467
399
  // Rename to final atomic path
468
400
  fs.renameSync(tmpOutputPath, outputPath);
@@ -470,7 +402,10 @@ export class TelegramFS {
470
402
  return outputPath;
471
403
  }
472
404
 
473
- async uploadFile(filename, tempPath) {
405
+ async uploadFile(filename, data) {
406
+ const { hashData } = await import('../crypto/encryption.js');
407
+ const hash = hashData(data);
408
+
474
409
  // Check if already exists by name
475
410
  const existingByName = this.db.findByName(filename);
476
411
  if (existingByName) {
@@ -482,17 +417,57 @@ export class TelegramFS {
482
417
  this.db.delete(existingByName.id);
483
418
  }
484
419
 
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
420
+ // Check if already exists by hash (same content, different name)
421
+ const existingByHash = this.db.findByHash(hash);
422
+ if (existingByHash) {
423
+ // Same content already exists, just skip
424
+ console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
425
+ return;
426
+ }
427
+
428
+ // Compress
429
+ const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
430
+
431
+ // Encrypt
432
+ const encryptedData = this.encryptor.encrypt(compressedData);
433
+
434
+ // Create temp file with header
435
+ const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
436
+ if (!fs.existsSync(tempDir)) {
437
+ fs.mkdirSync(tempDir, { recursive: true });
438
+ }
439
+
440
+ const flags = compressed ? 1 : 0;
441
+ const header = createHeader(filename, data.length, 0, 1, flags);
442
+ const fileData = Buffer.concat([header, encryptedData]);
443
+
444
+ const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
445
+ fs.writeFileSync(tempPath, fileData);
446
+
447
+ // Upload to Telegram
448
+ const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
449
+
450
+ // Add to index
451
+ const fileId = this.db.addFile({
452
+ filename,
453
+ hash,
454
+ originalSize: data.length,
455
+ storedSize: encryptedData.length,
456
+ chunks: 1,
457
+ compressed
491
458
  });
459
+
460
+ this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
461
+ this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
462
+ .run(result.fileId, fileId, 0);
463
+
464
+ // Cleanup
465
+ fs.unlinkSync(tempPath);
466
+ try { fs.rmdirSync(tempDir); } catch (e) { }
492
467
  }
493
468
 
494
469
  getCached(filename) {
495
- const entry = this.fileCache.get(filename);
470
+ const entry = fileCache.get(filename);
496
471
  if (!entry) return null;
497
472
 
498
473
  if (Date.now() - entry.timestamp > CACHE_TTL) {
@@ -500,7 +475,7 @@ export class TelegramFS {
500
475
  try {
501
476
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
502
477
  } catch (e) { }
503
- this.fileCache.delete(filename);
478
+ fileCache.delete(filename);
504
479
  return null;
505
480
  }
506
481
 
@@ -511,35 +486,35 @@ export class TelegramFS {
511
486
 
512
487
  setCache(filename, cachePath) {
513
488
  // Evict oldest entry if cache is full
514
- if (this.fileCache.size >= CACHE_MAX_ENTRIES) {
489
+ if (fileCache.size >= CACHE_MAX_ENTRIES) {
515
490
  let oldestKey = null;
516
491
  let oldestTime = Infinity;
517
- for (const [key, entry] of this.fileCache) {
492
+ for (const [key, entry] of fileCache) {
518
493
  if (entry.timestamp < oldestTime) {
519
494
  oldestTime = entry.timestamp;
520
495
  oldestKey = key;
521
496
  }
522
497
  }
523
498
  if (oldestKey) {
524
- const evicted = this.fileCache.get(oldestKey);
499
+ const evicted = fileCache.get(oldestKey);
525
500
  try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
526
- this.fileCache.delete(oldestKey);
501
+ fileCache.delete(oldestKey);
527
502
  }
528
503
  }
529
504
 
530
- this.fileCache.set(filename, {
505
+ fileCache.set(filename, {
531
506
  path: cachePath,
532
507
  timestamp: Date.now()
533
508
  });
534
509
  }
535
510
 
536
511
  invalidateCache(filename) {
537
- const entry = this.fileCache.get(filename);
512
+ const entry = fileCache.get(filename);
538
513
  if (entry) {
539
514
  try {
540
515
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
541
516
  } catch (e) { }
542
- this.fileCache.delete(filename);
517
+ fileCache.delete(filename);
543
518
  }
544
519
  }
545
520
 
@@ -552,17 +527,6 @@ export class TelegramFS {
552
527
  fs.mkdirSync(this.mountPoint, { recursive: true });
553
528
  }
554
529
 
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
-
566
530
  const ops = {
567
531
  getattr: this.getattr.bind(this),
568
532
  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 { 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
 
@@ -72,23 +72,15 @@ 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 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
- }
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
+ });
92
84
 
93
85
  onProgress?.('Processing and uploading streams...');
94
86
  let uploadedBytes = 0;
@@ -170,25 +162,15 @@ export async function processFile(filePath, options) {
170
162
  }
171
163
  });
172
164
 
173
- try {
174
- const readStream = fs.createReadStream(filePath);
165
+ const readStream = fs.createReadStream(filePath);
175
166
 
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
- }
167
+ // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
168
+ await pipeline(readStream, compressStream, encryptStream, chunkingStream);
184
169
 
185
170
  // Update the DB with the final accurate values
186
171
  db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
187
172
  .run(totalStoredSize, chunkIndex, fileId);
188
173
 
189
- // Commit the transaction — all DB rows are now permanent
190
- db.db.exec('COMMIT');
191
-
192
174
  db.close();
193
175
 
194
176
  // Clean up temp dir (only if empty)
@@ -228,86 +210,32 @@ export async function retrieveFile(fileRecord, options) {
228
210
  throw new Error('No chunk metadata found for this file');
229
211
  }
230
212
 
231
- // Prepare components
232
- const encryptor = new Encryptor(password);
233
- const decryptStream = encryptor.getDecryptStream();
234
-
235
- const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
236
- if (!fs.existsSync(tempDir)) {
237
- fs.mkdirSync(tempDir, { recursive: true });
238
- }
239
-
240
213
  // Connect to Telegram
241
214
  const client = new TelegramClient(dataDir);
242
215
  await client.initialize(config.botToken);
243
216
  client.setChatId(config.chatId);
244
217
 
245
- // Get total size from first chunk's header, or from DB
246
- const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
247
- const header = parseHeader(firstChunkData);
248
-
249
- // Total original uncompressed size
250
- let expectedOriginalSize = header.originalSize;
251
- let wasCompressed = header.compressed;
252
-
218
+ const encryptor = new Encryptor(password);
253
219
  const compressor = new Compressor();
254
- const decompressStream = compressor.getDecompressStream(wasCompressed);
255
-
256
- // We need a Readable stream that will lazily fetch chunks from Telegram
257
- // and push them into the decryption pipeline.
258
- const { Readable } = await import('stream');
259
-
260
- const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
261
- let downloadedBytes = 0;
262
-
263
- // Pre-sort chunks by index so we download them in correct order
264
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
265
-
266
- let currentChunkIndex = 0;
267
-
268
- // We already downloaded the first chunk to inspect its header, we shouldn't discard it.
269
- let preloadedFirstChunk = firstChunkData;
270
220
 
271
- const downloadStream = new Readable({
272
- async read() {
273
- try {
274
- if (currentChunkIndex >= chunks.length) {
275
- this.push(null); // End of stream
276
- return;
277
- }
278
-
279
- const chunk = chunks[currentChunkIndex];
280
- onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
281
-
282
- let data;
283
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
284
- data = preloadedFirstChunk;
285
- preloadedFirstChunk = null;
286
- } else {
287
- data = await client.downloadFile(chunk.file_telegram_id);
288
- }
221
+ const { createDownloadPipeline } = await import('./utils/download-stream.js');
289
222
 
290
- downloadedBytes += data.length;
291
- onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
292
-
293
- // Strip header before pushing
294
- const payload = data.subarray(HEADER_SIZE);
295
- this.push(payload);
296
-
297
- currentChunkIndex++;
298
- } catch (err) {
299
- this.destroy(err);
300
- }
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 });
301
231
  }
302
232
  });
303
233
 
304
234
  const writeStream = fs.createWriteStream(outputPath);
305
- const { pipeline } = await import('stream/promises');
306
235
 
307
236
  onProgress?.('Decrypting, decompressing, and writing file...');
308
237
 
309
- // Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
310
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
238
+ await pipeline(readable, writeStream);
311
239
 
312
240
  const finalStats = fs.statSync(outputPath);
313
241
 
@@ -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
@@ -239,7 +240,7 @@ export class ShareServer {
239
240
  this.password = options.password;
240
241
  this.config = options.config;
241
242
  this.port = options.port || 3000;
242
- this.host = options.host || '0.0.0.0';
243
+ this.host = options.host || '127.0.0.1';
243
244
 
244
245
  this.db = null;
245
246
  this.client = null;
@@ -261,64 +262,19 @@ export class ShareServer {
261
262
  }
262
263
 
263
264
  /**
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 }
265
+ * Stream a decrypted file from Telegram directly to the HTTP response
269
266
  */
270
- async prepareDownloadStreams(fileRecord) {
267
+ async streamToResponse(fileRecord, res) {
271
268
  const chunks = this.db.getChunks(fileRecord.id);
272
- if (chunks.length === 0) throw new Error('No chunks found');
273
-
274
- // Pre-sort chunks by index so we download them in correct order
275
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
276
-
277
- // Fetch and verify the first chunk eagerly
278
- const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
279
- const header = parseHeader(firstChunkData);
280
- let wasCompressed = header.compressed;
281
-
282
- // Prepare streams
283
- const decryptStream = this.encryptor.getDecryptStream();
284
- const decompressStream = this.compressor.getDecompressStream(wasCompressed);
285
-
286
- const { Readable } = await import('stream');
287
-
288
- const self = this;
289
- let currentChunkIndex = 0;
290
- let preloadedFirstChunk = firstChunkData;
291
-
292
- const downloadStream = new Readable({
293
- async read() {
294
- try {
295
- if (currentChunkIndex >= chunks.length) {
296
- this.push(null); // End of stream
297
- return;
298
- }
299
-
300
- const chunk = chunks[currentChunkIndex];
301
- let data;
302
-
303
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
304
- data = preloadedFirstChunk;
305
- preloadedFirstChunk = null;
306
- } else {
307
- data = await self.client.downloadFile(chunk.file_telegram_id);
308
- }
309
-
310
- // Strip header before pushing
311
- const payload = data.subarray(HEADER_SIZE);
312
- this.push(payload);
313
-
314
- currentChunkIndex++;
315
- } catch (err) {
316
- this.destroy(err);
317
- }
318
- }
269
+
270
+ const { readable } = await createDownloadPipeline({
271
+ client: this.client,
272
+ chunks,
273
+ encryptor: this.encryptor,
274
+ compressor: this.compressor
319
275
  });
320
276
 
321
- return { downloadStream, decryptStream, decompressStream };
277
+ await pipeline(readable, res);
322
278
  }
323
279
 
324
280
  /**
@@ -401,31 +357,14 @@ export class ShareServer {
401
357
  const contentType = contentTypes[ext] || 'application/octet-stream';
402
358
  const safeName = sanitizeFilenameForHeader(fileRecord.filename);
403
359
 
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
- });
360
+ res.writeHead(200, {
361
+ 'Content-Type': contentType,
362
+ 'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
363
+ 'Transfer-Encoding': 'chunked'
364
+ });
415
365
 
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
- }
366
+ // Download the file from Telegram, decrypt, decompress and stream directly to 'res'
367
+ await this.streamToResponse(fileRecord, res);
429
368
 
430
369
  } catch (err) {
431
370
  console.error('Share server error:', err.message);
package/src/sync/sync.js CHANGED
@@ -13,10 +13,6 @@ import { processFile } from '../index.js';
13
13
  // Debounce time in ms to batch rapid file changes
14
14
  const DEBOUNCE_MS = 1000;
15
15
 
16
- // Exponential backoff limits for upload errors
17
- const INITIAL_BACKOFF_MS = 2000;
18
- const MAX_BACKOFF_MS = 60000;
19
-
20
16
  // Ignore patterns
21
17
  const IGNORE_PATTERNS = [
22
18
  /^\./, // Hidden files
@@ -35,7 +31,6 @@ export class SyncEngine extends EventEmitter {
35
31
  this.config = options.config;
36
32
  this.limitRate = options.limitRate || null;
37
33
  this.watchers = new Map(); // path -> FSWatcher
38
- this.rootWatchers = new Map(); // rootPath -> Set of watched subpaths (for Linux recursive watch fallback)
39
34
  this.pendingChanges = new Map(); // path -> timeout
40
35
  this.db = null;
41
36
  this.running = false;
@@ -110,8 +105,6 @@ export class SyncEngine extends EventEmitter {
110
105
  const promises = [];
111
106
 
112
107
  const worker = async () => {
113
- let consecutiveErrors = 0;
114
-
115
108
  while (queue.length > 0) {
116
109
  const file = queue.shift();
117
110
  const existing = stateMap.get(file.relativePath);
@@ -148,7 +141,6 @@ export class SyncEngine extends EventEmitter {
148
141
  // Update sync state
149
142
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
150
143
  uploaded++;
151
- consecutiveErrors = 0; // Reset on success
152
144
 
153
145
  this.emit('file-upload-complete', { file: file.relativePath });
154
146
  } catch (err) {
@@ -156,12 +148,9 @@ export class SyncEngine extends EventEmitter {
156
148
  if (err.message.includes('duplicate')) {
157
149
  this.db.updateSyncState(folder.id, file.relativePath, hash, file.mtime);
158
150
  skipped++;
159
- consecutiveErrors = 0;
160
151
  } else {
161
- consecutiveErrors++;
162
- // Exponential backoff: 2s → 4s → 8s → 16s → ... → 60s max
163
- const delay = Math.min(INITIAL_BACKOFF_MS * Math.pow(2, consecutiveErrors - 1), MAX_BACKOFF_MS);
164
- await new Promise(r => setTimeout(r, delay));
152
+ // Sleep briefly on non-duplicate error (potential rate limits)
153
+ await new Promise(r => setTimeout(r, 2000));
165
154
  this.emit('file-upload-error', { file: file.relativePath, error: err.message });
166
155
  }
167
156
  }
@@ -249,148 +238,37 @@ export class SyncEngine extends EventEmitter {
249
238
  }
250
239
  }
251
240
 
252
- /**
253
- * Recursively list all subdirectories of a directory
254
- */
255
- getSubdirectories(dirPath) {
256
- const subdirs = [];
257
- try {
258
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
259
- for (const entry of entries) {
260
- if (entry.isDirectory()) {
261
- if (this.shouldIgnore(entry.name)) continue;
262
- const fullPath = path.join(dirPath, entry.name);
263
- subdirs.push(fullPath);
264
- subdirs.push(...this.getSubdirectories(fullPath));
265
- }
266
- }
267
- } catch (err) {
268
- // Ignore folder reading errors
269
- }
270
- return subdirs;
271
- }
272
-
273
- /**
274
- * Watch a single directory (non-recursively) and dynamically watch new folders
275
- */
276
- watchSingleDir(rootPath, dir) {
277
- if (this.watchers.has(dir)) return;
278
-
279
- try {
280
- const watcher = fs.watch(dir, { recursive: false }, (event, filename) => {
281
- if (!filename) return;
282
-
283
- const fullPath = path.join(dir, filename);
284
- const relativePath = path.relative(rootPath, fullPath);
285
-
286
- // If a new directory is created, watch it recursively
287
- try {
288
- if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
289
- if (!this.shouldIgnore(filename)) {
290
- this.watchDirRecursively(rootPath, fullPath);
291
- }
292
- }
293
- } catch (e) {
294
- // Ignore stats errors
295
- }
296
-
297
- this.handleFileChange(rootPath, relativePath);
298
- });
299
-
300
- watcher.on('error', (err) => {
301
- this.emit('watch-error', { folder: rootPath, error: err.message });
302
- });
303
-
304
- this.watchers.set(dir, watcher);
305
-
306
- if (!this.rootWatchers.has(rootPath)) {
307
- this.rootWatchers.set(rootPath, new Set());
308
- }
309
- this.rootWatchers.get(rootPath).add(dir);
310
- } catch (err) {
311
- this.emit('watch-error', { folder: rootPath, error: err.message });
312
- }
313
- }
314
-
315
- /**
316
- * Recursively watch a newly created directory and its subdirectories
317
- */
318
- watchDirRecursively(rootPath, dirPath) {
319
- this.watchSingleDir(rootPath, dirPath);
320
- const subdirs = this.getSubdirectories(dirPath);
321
- for (const subdir of subdirs) {
322
- this.watchSingleDir(rootPath, subdir);
323
- }
324
- }
325
-
326
241
  /**
327
242
  * Start watching a folder
328
243
  */
329
244
  watchFolder(folderPath) {
330
- const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
331
-
332
- if (isRecursiveSupported) {
333
- if (this.watchers.has(folderPath)) {
334
- return; // Already watching
335
- }
336
-
337
- const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
338
- if (filename) {
339
- this.handleFileChange(folderPath, filename);
340
- }
341
- });
342
-
343
- watcher.on('error', (err) => {
344
- this.emit('watch-error', { folder: folderPath, error: err.message });
345
- });
245
+ if (this.watchers.has(folderPath)) {
246
+ return; // Already watching
247
+ }
346
248
 
347
- this.watchers.set(folderPath, watcher);
348
- this.emit('watch-start', { folder: folderPath });
349
- } else {
350
- // Manual recursive watch for Linux/other platforms
351
- if (this.rootWatchers.has(folderPath)) {
352
- return; // Already watching
249
+ const watcher = fs.watch(folderPath, { recursive: true }, (event, filename) => {
250
+ if (filename) {
251
+ this.handleFileChange(folderPath, filename);
353
252
  }
253
+ });
354
254
 
355
- // Watch root
356
- this.watchSingleDir(folderPath, folderPath);
255
+ watcher.on('error', (err) => {
256
+ this.emit('watch-error', { folder: folderPath, error: err.message });
257
+ });
357
258
 
358
- // Watch all existing subdirectories
359
- const subdirs = this.getSubdirectories(folderPath);
360
- for (const subdir of subdirs) {
361
- this.watchSingleDir(folderPath, subdir);
362
- }
363
-
364
- this.emit('watch-start', { folder: folderPath });
365
- }
259
+ this.watchers.set(folderPath, watcher);
260
+ this.emit('watch-start', { folder: folderPath });
366
261
  }
367
262
 
368
263
  /**
369
264
  * Stop watching a folder
370
265
  */
371
266
  unwatchFolder(folderPath) {
372
- const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
373
-
374
- if (isRecursiveSupported) {
375
- const watcher = this.watchers.get(folderPath);
376
- if (watcher) {
377
- watcher.close();
378
- this.watchers.delete(folderPath);
379
- this.emit('watch-stop', { folder: folderPath });
380
- }
381
- } else {
382
- if (this.rootWatchers.has(folderPath)) {
383
- const dirs = this.rootWatchers.get(folderPath);
384
- for (const dir of dirs) {
385
- const watcher = this.watchers.get(dir);
386
- if (watcher) {
387
- watcher.close();
388
- this.watchers.delete(dir);
389
- }
390
- }
391
- this.rootWatchers.delete(folderPath);
392
- this.emit('watch-stop', { folder: folderPath });
393
- }
267
+ const watcher = this.watchers.get(folderPath);
268
+ if (watcher) {
269
+ watcher.close();
270
+ this.watchers.delete(folderPath);
271
+ this.emit('watch-stop', { folder: folderPath });
394
272
  }
395
273
  }
396
274
 
@@ -424,25 +302,11 @@ export class SyncEngine extends EventEmitter {
424
302
  this.pendingChanges.clear();
425
303
 
426
304
  // Close all watchers
427
- const isRecursiveSupported = process.platform === 'darwin' || process.platform === 'win32';
428
- if (isRecursiveSupported) {
429
- for (const [folderPath, watcher] of this.watchers) {
430
- watcher.close();
431
- this.emit('watch-stop', { folder: folderPath });
432
- }
433
- this.watchers.clear();
434
- } else {
435
- for (const rootPath of this.rootWatchers.keys()) {
436
- this.unwatchFolder(rootPath);
437
- }
438
- this.rootWatchers.clear();
439
-
440
- // Just in case, close any stray watchers
441
- for (const watcher of this.watchers.values()) {
442
- try { watcher.close(); } catch (e) {}
443
- }
444
- this.watchers.clear();
305
+ for (const [folderPath, watcher] of this.watchers) {
306
+ watcher.close();
307
+ this.emit('watch-stop', { folder: folderPath });
445
308
  }
309
+ this.watchers.clear();
446
310
 
447
311
  if (this.db) {
448
312
  this.db.close();
@@ -3,13 +3,6 @@
3
3
  */
4
4
 
5
5
  import chalk from 'chalk';
6
- import { readFileSync } from 'fs';
7
- import { fileURLToPath } from 'url';
8
- import { dirname, join } from 'path';
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
- const __dirname = dirname(__filename);
12
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8'));
13
6
 
14
7
  export const LOGO = `
15
8
  ████████╗ █████╗ ███████╗
@@ -21,7 +14,7 @@ export const LOGO = `
21
14
  `;
22
15
 
23
16
  export const TAGLINE = 'Telegram as Storage';
24
- export const VERSION = pkg.version;
17
+ export const VERSION = '2.4.0';
25
18
 
26
19
  /**
27
20
  * Print the TAS banner
@@ -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
+ }