@nightowne/tas-cli 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,25 @@
1
- # Telegram as Storage
1
+ <p align="center">
2
+ <img src="assets/demo.gif" alt="TAS Demo" width="600">
3
+ </p>
4
+
5
+ <h1 align="center">Telegram as Storage</h1>
2
6
 
3
- [![CI](https://github.com/ixchio/tas/actions/workflows/ci.yml/badge.svg)](https://github.com/ixchio/tas/actions/workflows/ci.yml)
4
- [![npm](https://img.shields.io/npm/v/@nightowne/tas-cli)](https://www.npmjs.com/package/@nightowne/tas-cli)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ <p align="center">
8
+ <strong>Free, encrypted, unlimited cloud storage — inside Telegram.</strong>
9
+ </p>
6
10
 
7
11
  <p align="center">
8
- <img src="assets/demo.gif" alt="Demo" width="600">
12
+ <a href="https://github.com/ixchio/tas/actions/workflows/ci.yml"><img src="https://github.com/ixchio/tas/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
13
+ <a href="https://www.npmjs.com/package/@nightowne/tas-cli"><img src="https://img.shields.io/npm/v/@nightowne/tas-cli" alt="npm"></a>
14
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
15
+ <a href="https://www.npmjs.com/package/@nightowne/tas-cli"><img src="https://img.shields.io/npm/dm/@nightowne/tas-cli" alt="Downloads"></a>
9
16
  </p>
10
17
 
11
- A CLI tool that uses your Telegram bot as encrypted file storage. Files are compressed, encrypted locally, then uploaded to your private bot chat.
18
+ ---
19
+
20
+ I built this because I wanted encrypted cloud storage that's actually free. Google Drive reads your files. Dropbox costs money. Telegram gives you unlimited storage with a bot API — so I wrote a CLI that turns it into a proper encrypted drive.
21
+
22
+ **What this does:** Compresses, encrypts (AES-256-GCM), and uploads your files to your own private Telegram bot chat. Mount it as a folder, sync directories, or share files with expiring links.
12
23
 
13
24
  ```
14
25
  ┌─────────────┐ ┌───────────────┐ ┌──────────────┐
@@ -22,162 +33,130 @@ A CLI tool that uses your Telegram bot as encrypted file storage. Files are comp
22
33
  └─────────────┘ └───────────────┘ └──────────────┘
23
34
  ```
24
35
 
25
- ## Why TAS?
26
-
27
- | Feature | TAS | Session-based tools (e.g. teldrive) |
28
- |---------|:---:|:-----------------------------------:|
29
- | Account ban risk | None (Bot API) | High (session hijack detection) |
30
- | Encryption | AES-256-GCM | Usually none |
31
- | Dependencies | SQLite only | Rclone, external DB |
32
- | Setup complexity | 2 minutes | Docker + multiple services |
33
-
34
- Key differences:
35
- - Uses Bot API, not session-based auth - Telegram can't ban your account
36
- - Encryption by default - files encrypted before leaving your machine
37
- - Local-first - SQLite index, no cloud dependencies
38
- - FUSE mount - use Telegram like a folder
39
-
40
- ## Security Model
36
+ ## Quick Start
41
37
 
42
- | Component | Implementation |
43
- |-----------|----------------|
44
- | Cipher | AES-256-GCM |
45
- | Key derivation | PBKDF2-SHA512, 100,000 iterations |
46
- | Salt | 32 bytes, random per file |
47
- | IV | 12 bytes, random per file |
48
- | Auth tag | 16 bytes (integrity) |
49
-
50
- Your password never leaves your machine. Telegram stores encrypted blobs.
38
+ ```bash
39
+ npm install -g @nightowne/tas-cli
40
+ tas init # 2-minute setup wizard
41
+ tas push secret.pdf
42
+ tas pull secret.pdf
43
+ ```
51
44
 
52
- ## Limitations
45
+ ## 🔥 Features
53
46
 
54
- - Not a backup - Telegram can delete content without notice
55
- - No versioning - overwriting a file deletes the old version
56
- - 49MB chunks - files split due to Bot API limits
57
- - FUSE required - mount feature needs libfuse on Linux/macOS
58
- - Single user - designed for personal use, not multi-tenant
47
+ ### Mount as a folder
48
+ ```bash
49
+ tas mount ~/cloud # FUSE mount drag & drop files
50
+ tas unmount ~/cloud
51
+ ```
52
+ Requires `libfuse` (`apt install fuse libfuse-dev` on Debian/Ubuntu, `brew install macfuse` on macOS).
59
53
 
60
- ## Quick Start
54
+ ### Auto-sync folders
55
+ ```bash
56
+ tas sync add ~/Documents # Register
57
+ tas sync start # Watch for changes, auto-upload
58
+ tas sync pull # Download everything back
59
+ ```
61
60
 
61
+ ### Share files with expiring links
62
62
  ```bash
63
- # Install
64
- npm install -g @nightowne/tas-cli
63
+ tas share create secret.pdf --expire 1h --max-downloads 3
64
+ # http://localhost:3000/d/a1b2c3d4...
65
65
 
66
- # Setup (creates bot connection + encryption password)
67
- tas init
66
+ tas share list # Active shares
67
+ tas share revoke a1b2c3d4 # Revoke
68
+ ```
69
+ Spins up a local HTTP server. File is downloaded from Telegram, decrypted, and served. Dark-themed download page with file info.
68
70
 
69
- # Upload a file
70
- tas push secret.pdf
71
+ ## 🛡️ Security
71
72
 
72
- # Download a file
73
- tas pull secret.pdf
73
+ | Component | Implementation |
74
+ |-----------|----------------|
75
+ | Cipher | AES-256-GCM |
76
+ | Key derivation | PBKDF2-SHA512, 100k iterations |
77
+ | Salt | 32 bytes, random per file |
78
+ | IV | 12 bytes, random per file |
79
+ | Auth tag | 16 bytes (integrity verification) |
74
80
 
75
- # Mount as folder (requires libfuse)
76
- tas mount ~/cloud
77
- ```
81
+ Your password **never** leaves your machine. Telegram only sees encrypted blobs. Even if someone accesses your bot chat, they get meaningless data without your password.
78
82
 
79
- ### Prerequisites
80
- - Node.js ≥18
81
- - Telegram account + bot token from [@BotFather](https://t.me/BotFather)
82
- - `libfuse` for mount feature:
83
- ```bash
84
- # Debian/Ubuntu
85
- sudo apt install fuse libfuse-dev
86
-
87
- # Fedora
88
- sudo dnf install fuse fuse-devel
89
-
90
- # macOS
91
- brew install macfuse
92
- ```
93
-
94
- ## CLI Reference
83
+ ## 📋 Full CLI Reference
95
84
 
96
85
  ```bash
97
86
  # Core
98
87
  tas init # Setup wizard
99
- tas push <file> # Upload file
100
- tas pull <file|hash> # Download file
101
- tas list [-l] # List files (long format)
102
- tas delete <file|hash> # Remove file
103
- tas status # Show stats
104
-
105
- # Search & Resume (v1.1.0)
106
- tas search <query> # Search by filename
107
- tas search -t <query> # Search by tag
88
+ tas push <file> # Upload
89
+ tas pull <file|hash> # Download
90
+ tas list [-l] # List files
91
+ tas delete <file|hash> # Delete
92
+ tas status # Stats
93
+ tas search <query> # Search files
108
94
  tas resume # Resume interrupted uploads
95
+ tas verify # Check integrity
109
96
 
110
97
  # FUSE Mount
111
98
  tas mount <path> # Mount as folder
112
99
  tas unmount <path> # Unmount
113
100
 
114
- # Tags
115
- tas tag add <file> <tags...> # Add tags
116
- tas tag remove <file> <tags...> # Remove tags
117
- tas tag list [tag] # List tags or files by tag
118
-
119
- # Sync (Dropbox-style)
120
- tas sync add <folder> # Register folder for sync
101
+ # Sync
102
+ tas sync add <folder> # Register folder
121
103
  tas sync start # Start watching
122
- tas sync pull # Download all to sync folders
123
- tas sync status # Show sync status
104
+ tas sync pull # Download all
105
+ tas sync status # Show status
124
106
 
125
- # Share (temporary links)
107
+ # Share
126
108
  tas share create <file> # Create download link
127
- tas share create <file> --expire 1h --max-downloads 3
128
- tas share list # Show active shares
129
- tas share revoke <token> # Revoke a share
109
+ tas share list # Active shares
110
+ tas share revoke <token> # Revoke
130
111
 
131
- # Verification
132
- tas verify # Check file integrity
112
+ # Tags
113
+ tas tag add <file> <tags...>
114
+ tas tag remove <file> <tags...>
115
+ tas tag list [tag]
133
116
  ```
134
117
 
135
- ## Automation
136
-
137
- Skip password prompts for scripts and CI/CD:
118
+ ## 🤖 Automation
138
119
 
139
120
  ```bash
140
- # Environment variable
121
+ # Skip password prompts
141
122
  export TAS_PASSWORD="your-password"
142
123
  tas push file.pdf
143
- tas sync start
144
124
 
145
- # Or use CLI flag (recommended for CI/CD)
125
+ # Or inline
146
126
  tas push -p "password" file.pdf
147
127
  ```
148
128
 
149
- Works great with:
150
- - Cron jobs for automated backups
151
- - GitHub Actions and GitLab CI
152
- - Docker containers
153
- - Shell scripts for batch operations
129
+ Works with cron, GitHub Actions, Docker, or any CI/CD.
154
130
 
155
- ## Auto-Start (systemd)
131
+ ## ⚠️ Limitations
156
132
 
157
- See [systemd/README.md](systemd/README.md) for running sync as a service.
133
+ - **Not a backup** Telegram can delete content without notice
134
+ - **No versioning** — overwriting deletes the old version
135
+ - **49MB chunks** — files split due to Bot API limits
136
+ - **Single user** — personal use, not multi-tenant
137
+ - **FUSE required** — mount feature needs libfuse
158
138
 
159
- ## Development
139
+ ## 🛠️ Development
160
140
 
161
141
  ```bash
162
142
  git clone https://github.com/ixchio/tas
163
- cd tas
164
- npm install
165
- npm test # 28 tests
143
+ cd tas && npm install
144
+ npm test # 43 tests
166
145
  ```
167
146
 
168
- ### Project Structure
169
147
  ```
170
148
  src/
171
- ├── cli.js # Command definitions
149
+ ├── cli.js # Commands
172
150
  ├── index.js # Upload/download pipeline
173
- ├── crypto/ # AES-256-GCM encryption
174
- ├── db/ # SQLite file index
175
- ├── fuse/ # FUSE filesystem mount
151
+ ├── crypto/ # AES-256-GCM
152
+ ├── db/ # SQLite index
153
+ ├── fuse/ # FUSE filesystem
154
+ ├── share/ # HTTP share server
176
155
  ├── sync/ # Folder sync engine
177
156
  ├── telegram/ # Bot API client
178
157
  └── utils/ # Compression, chunking
179
158
  ```
180
159
 
181
- ## License
160
+ ## 📄 License
182
161
 
183
- MIT
162
+ MIT — do whatever you want with it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
package/src/cli.js CHANGED
@@ -674,19 +674,38 @@ syncCmd
674
674
  .command('start')
675
675
  .description('Start syncing all registered folders')
676
676
  .option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
677
+ .option('-l, --limit <limit>', 'Bandwidth limit (e.g. 500k, 1m)')
677
678
  .action(async (options) => {
678
679
  console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
679
680
 
680
681
  const config = requireConfig(DATA_DIR);
681
682
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
682
683
 
684
+ let limitRate = null;
685
+ if (options.limit) {
686
+ const match = options.limit.match(/^(\d+)([kmg]?)$/i);
687
+ if (!match) {
688
+ console.error(chalk.red('Invalid limit format. Use e.g. 500{}, 1m'));
689
+ process.exit(1);
690
+ }
691
+ const val = parseInt(match[1]);
692
+ const unit = match[2].toLowerCase();
693
+ if (unit === 'k') limitRate = val * 1024;
694
+ else if (unit === 'm') limitRate = val * 1024 * 1024;
695
+ else if (unit === 'g') limitRate = val * 1024 * 1024 * 1024;
696
+ else limitRate = val;
697
+
698
+ console.log(chalk.dim(` Bandwidth limit: ${options.limit}/s`));
699
+ }
700
+
683
701
  try {
684
702
  const { SyncEngine } = await import('./sync/sync.js');
685
703
 
686
704
  const syncEngine = new SyncEngine({
687
705
  dataDir: DATA_DIR,
688
706
  password,
689
- config
707
+ config,
708
+ limitRate
690
709
  });
691
710
 
692
711
  await syncEngine.initialize();
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import crypto from 'crypto';
6
+ import { Transform } from 'stream';
6
7
 
7
8
  const ALGORITHM = 'aes-256-gcm';
8
9
  const KEY_LENGTH = 32; // 256 bits
@@ -66,6 +67,41 @@ export class Encryptor {
66
67
  return Buffer.concat([salt, iv, encrypted, authTag]);
67
68
  }
68
69
 
70
+ /**
71
+ * Get an encryption transform stream
72
+ * Needs to append the salt/iv to the stream begin, and authTag to the stream end
73
+ */
74
+ getEncryptStream() {
75
+ const salt = crypto.randomBytes(SALT_LENGTH);
76
+ const iv = crypto.randomBytes(IV_LENGTH);
77
+ const key = this.deriveKey(salt);
78
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
79
+
80
+ let headerWritten = false;
81
+
82
+ return new Transform({
83
+ transform(chunk, encoding, callback) {
84
+ if (!headerWritten) {
85
+ this.push(Buffer.concat([salt, iv]));
86
+ headerWritten = true;
87
+ }
88
+ const encrypted = cipher.update(chunk);
89
+ if (encrypted.length > 0) {
90
+ this.push(encrypted);
91
+ }
92
+ callback();
93
+ },
94
+ flush(callback) {
95
+ const final = cipher.final();
96
+ if (final.length > 0) {
97
+ this.push(final);
98
+ }
99
+ this.push(cipher.getAuthTag());
100
+ callback();
101
+ }
102
+ });
103
+ }
104
+
69
105
  /**
70
106
  * Decrypt data
71
107
  * Input: Buffer containing [salt (32) | iv (12) | ciphertext | authTag (16)]
@@ -90,6 +126,98 @@ export class Encryptor {
90
126
  decipher.final()
91
127
  ]);
92
128
  }
129
+
130
+ /**
131
+ * Get a decryption transform stream
132
+ * Expects [salt (32) | iv (12) | ciphertext | authTag (16)]
133
+ */
134
+ getDecryptStream() {
135
+ let salt = null;
136
+ let iv = null;
137
+ let authTag = null;
138
+ let key = null;
139
+ let decipher = null;
140
+
141
+ // Buffer for storing the salt and iv during the first few chunks
142
+ let headerBuffer = Buffer.alloc(0);
143
+ let headerRead = false;
144
+
145
+ // We must buffer the last 16 bytes across chunks because it's the authTag
146
+ let tailBuffer = Buffer.alloc(0);
147
+
148
+ const self = this;
149
+
150
+ return new Transform({
151
+ transform(chunk, encoding, callback) {
152
+ try {
153
+ // 1. Read the header (salt + iv)
154
+ if (!headerRead) {
155
+ headerBuffer = Buffer.concat([headerBuffer, chunk]);
156
+
157
+ if (headerBuffer.length >= SALT_LENGTH + IV_LENGTH) {
158
+ salt = headerBuffer.subarray(0, SALT_LENGTH);
159
+ iv = headerBuffer.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
160
+ key = self.deriveKey(salt);
161
+ decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
162
+
163
+ // The rest of the header buffer is ciphertext
164
+ const remaining = headerBuffer.subarray(SALT_LENGTH + IV_LENGTH);
165
+ headerBuffer = null; // free memory
166
+ headerRead = true;
167
+
168
+ // Push remaining into tailBuffer for processing
169
+ if (remaining.length > 0) {
170
+ tailBuffer = Buffer.concat([tailBuffer, remaining]);
171
+ }
172
+ }
173
+ } else {
174
+ tailBuffer = Buffer.concat([tailBuffer, chunk]);
175
+ }
176
+
177
+ // 2. Process ciphertext, keeping exactly TAG_LENGTH bytes in tailBuffer
178
+ if (headerRead && tailBuffer.length > TAG_LENGTH) {
179
+ const processLength = tailBuffer.length - TAG_LENGTH;
180
+ const toProcess = tailBuffer.subarray(0, processLength);
181
+
182
+ const decrypted = decipher.update(toProcess);
183
+ if (decrypted.length > 0) {
184
+ this.push(decrypted);
185
+ }
186
+
187
+ // Keep only the end
188
+ tailBuffer = tailBuffer.subarray(processLength);
189
+ }
190
+
191
+ callback();
192
+ } catch (err) {
193
+ callback(err);
194
+ }
195
+ },
196
+ flush(callback) {
197
+ try {
198
+ if (!headerRead) {
199
+ return callback(new Error('Invalid encrypted data stream: too short'));
200
+ }
201
+
202
+ if (tailBuffer.length !== TAG_LENGTH) {
203
+ return callback(new Error(`Invalid encrypted data stream: missing auth tag. Got ${tailBuffer.length} bytes, expected ${TAG_LENGTH}`));
204
+ }
205
+
206
+ authTag = tailBuffer;
207
+ decipher.setAuthTag(authTag);
208
+
209
+ const final = decipher.final();
210
+ if (final.length > 0) {
211
+ this.push(final);
212
+ }
213
+
214
+ callback();
215
+ } catch (err) {
216
+ callback(new Error(`Decryption failed: wrong password or corrupt data (${err.message})`));
217
+ }
218
+ }
219
+ });
220
+ }
93
221
  }
94
222
 
95
223
  /**
package/src/fuse/mount.js CHANGED
@@ -130,26 +130,35 @@ export class TelegramFS {
130
130
  }
131
131
 
132
132
  /**
133
- * Read file contents
133
+ * Read file contents from disk cache
134
134
  */
135
135
  async read(filepath, fd, buffer, length, position, cb) {
136
136
  const filename = path.basename(filepath);
137
137
 
138
138
  try {
139
+ // Check write buffers first
140
+ const wb = this.writeBuffers.get(filename);
141
+ if (wb) {
142
+ const slice = wb.data.subarray(position, position + length);
143
+ slice.copy(buffer);
144
+ return cb(slice.length);
145
+ }
146
+
139
147
  // Check cache first
140
- let data = this.getCached(filename);
148
+ let cachedPath = this.getCached(filename);
141
149
 
142
- if (!data) {
143
- // Download and decrypt
144
- data = await this.downloadFile(filename);
145
- this.setCache(filename, data);
150
+ if (!cachedPath) {
151
+ // Download, decrypt, and save to disk cache
152
+ cachedPath = await this.downloadFileToCache(filename);
153
+ this.setCache(filename, cachedPath);
146
154
  }
147
155
 
148
- // Copy requested portion to buffer
149
- const slice = data.subarray(position, position + length);
150
- slice.copy(buffer);
156
+ // Copy requested portion to buffer from disk
157
+ const fdDisk = fs.openSync(cachedPath, 'r');
158
+ const bytesRead = fs.readSync(fdDisk, buffer, 0, length, position);
159
+ fs.closeSync(fdDisk);
151
160
 
152
- return cb(slice.length);
161
+ return cb(bytesRead);
153
162
  } catch (err) {
154
163
  console.error('Read error:', err.message);
155
164
  return cb(Fuse.EIO);
@@ -305,10 +314,10 @@ export class TelegramFS {
305
314
 
306
315
  // Get or load into write buffer
307
316
  if (!this.writeBuffers.has(filename)) {
308
- const cached = this.getCached(filename);
309
- if (cached) {
317
+ const cachedPath = this.getCached(filename);
318
+ if (cachedPath) {
310
319
  this.writeBuffers.set(filename, {
311
- data: Buffer.from(cached),
320
+ data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
312
321
  modified: true
313
322
  });
314
323
  } else {
@@ -335,38 +344,81 @@ export class TelegramFS {
335
344
 
336
345
  // ============== Helper Methods ==============
337
346
 
338
- async downloadFile(filename) {
347
+ async downloadFileToCache(filename) {
339
348
  const file = this.db.findByName(filename);
340
349
  if (!file) throw new Error('File not found');
341
350
 
351
+ const cacheDir = path.join(this.dataDir, 'cache');
352
+ if (!fs.existsSync(cacheDir)) {
353
+ fs.mkdirSync(cacheDir, { recursive: true });
354
+ }
355
+
356
+ const outputPath = path.join(cacheDir, file.hash);
357
+
358
+ // If it's already fully downloaded and cached on disk, return path
359
+ if (fs.existsSync(outputPath)) {
360
+ const stats = fs.statSync(outputPath);
361
+ if (stats.size === file.original_size) {
362
+ return outputPath;
363
+ }
364
+ }
365
+
342
366
  const chunks = this.db.getChunks(file.id);
343
367
  if (chunks.length === 0) throw new Error('No chunks found');
344
368
 
345
- // Download all chunks
346
- const downloadedChunks = [];
347
-
348
- for (const chunk of chunks) {
349
- const data = await this.client.downloadFile(chunk.file_telegram_id);
350
- const header = parseHeader(data);
351
- const payload = data.subarray(HEADER_SIZE);
369
+ // Pre-sort chunks
370
+ chunks.sort((a, b) => a.chunk_index - b.chunk_index);
371
+
372
+ const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
373
+ const header = parseHeader(firstChunkData);
374
+ let wasCompressed = header.compressed;
375
+
376
+ const decryptStream = this.encryptor.getDecryptStream();
377
+ const decompressStream = this.compressor.getDecompressStream(wasCompressed);
378
+
379
+ const { Readable } = await import('stream');
380
+ const { pipeline } = await import('stream/promises');
381
+
382
+ const self = this;
383
+ let currentChunkIndex = 0;
384
+ let preloadedFirstChunk = firstChunkData;
385
+
386
+ const downloadStream = new Readable({
387
+ async read() {
388
+ try {
389
+ if (currentChunkIndex >= chunks.length) {
390
+ this.push(null);
391
+ return;
392
+ }
393
+
394
+ const chunk = chunks[currentChunkIndex];
395
+ let data;
396
+ if (currentChunkIndex === 0 && preloadedFirstChunk) {
397
+ data = preloadedFirstChunk;
398
+ preloadedFirstChunk = null;
399
+ } else {
400
+ data = await self.client.downloadFile(chunk.file_telegram_id);
401
+ }
402
+
403
+ const payload = data.subarray(HEADER_SIZE);
404
+ this.push(payload);
405
+ currentChunkIndex++;
406
+ } catch (err) {
407
+ this.destroy(err);
408
+ }
409
+ }
410
+ });
352
411
 
353
- downloadedChunks.push({
354
- index: header.chunkIndex,
355
- data: payload,
356
- compressed: header.compressed
357
- });
358
- }
412
+ const tmpOutputPath = outputPath + '.tmp';
413
+ const writeStream = fs.createWriteStream(tmpOutputPath);
359
414
 
360
- // Reassemble
361
- downloadedChunks.sort((a, b) => a.index - b.index);
362
- const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
415
+ // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
416
+ await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
363
417
 
364
- // Decrypt
365
- const compressedData = this.encryptor.decrypt(encryptedData);
418
+ // Rename to final atomic path
419
+ fs.renameSync(tmpOutputPath, outputPath);
366
420
 
367
- // Decompress
368
- const wasCompressed = downloadedChunks[0].compressed;
369
- return await this.compressor.decompress(compressedData, wasCompressed);
421
+ return outputPath;
370
422
  }
371
423
 
372
424
  async uploadFile(filename, data) {
@@ -399,7 +451,7 @@ export class TelegramFS {
399
451
  const encryptedData = this.encryptor.encrypt(compressedData);
400
452
 
401
453
  // Create temp file with header
402
- const tempDir = path.join(this.dataDir, 'tmp');
454
+ const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
403
455
  if (!fs.existsSync(tempDir)) {
404
456
  fs.mkdirSync(tempDir, { recursive: true });
405
457
  }
@@ -438,22 +490,34 @@ export class TelegramFS {
438
490
  if (!entry) return null;
439
491
 
440
492
  if (Date.now() - entry.timestamp > CACHE_TTL) {
493
+ // Expired, delete the file if possible
494
+ try {
495
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
496
+ } catch (e) { }
441
497
  fileCache.delete(filename);
442
498
  return null;
443
499
  }
444
500
 
445
- return entry.data;
501
+ // Extend cache TTL on read
502
+ entry.timestamp = Date.now();
503
+ return entry.path;
446
504
  }
447
505
 
448
- setCache(filename, data) {
506
+ setCache(filename, cachePath) {
449
507
  fileCache.set(filename, {
450
- data,
508
+ path: cachePath,
451
509
  timestamp: Date.now()
452
510
  });
453
511
  }
454
512
 
455
513
  invalidateCache(filename) {
456
- fileCache.delete(filename);
514
+ const entry = fileCache.get(filename);
515
+ if (entry) {
516
+ try {
517
+ if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
518
+ } catch (e) { }
519
+ fileCache.delete(filename);
520
+ }
457
521
  }
458
522
 
459
523
  /**