@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 +19 -34
- package/package.json +2 -2
- package/src/cli.js +2 -0
- package/src/crypto/encryption.js +15 -2
- package/src/fuse/mount.js +137 -173
- package/src/index.js +24 -96
- package/src/share/server.js +19 -80
- package/src/sync/sync.js +23 -159
- package/src/utils/branding.js +1 -8
- package/src/utils/download-stream.js +87 -0
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** |
|
|
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
|
|
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
|
|
294
|
-
├── index.js
|
|
291
|
+
├── cli.js # Commander-based CLI — all commands
|
|
292
|
+
├── index.js # Streaming upload/download pipeline
|
|
295
293
|
├── crypto/
|
|
296
|
-
│ └── encryption.js
|
|
294
|
+
│ └── encryption.js # AES-256-GCM + PBKDF2-SHA512 key derivation
|
|
297
295
|
├── db/
|
|
298
|
-
│ └── index.js
|
|
296
|
+
│ └── index.js # SQLite index (files, chunks, tags, shares, sync)
|
|
299
297
|
├── telegram/
|
|
300
|
-
│ └── client.js
|
|
298
|
+
│ └── client.js # Bot API wrapper — retry, rate-limit, streaming
|
|
301
299
|
├── fuse/
|
|
302
|
-
│ └── mount.js
|
|
300
|
+
│ └── mount.js # FUSE filesystem — mount Telegram as a folder
|
|
303
301
|
├── share/
|
|
304
|
-
│ └── server.js
|
|
302
|
+
│ └── server.js # HTTP server — expiring download links
|
|
305
303
|
├── sync/
|
|
306
|
-
│ └── sync.js
|
|
304
|
+
│ └── sync.js # Folder watcher — Dropbox-style auto-sync
|
|
307
305
|
└── utils/
|
|
308
|
-
├──
|
|
309
|
-
├──
|
|
310
|
-
├──
|
|
311
|
-
├──
|
|
312
|
-
├──
|
|
313
|
-
|
|
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 #
|
|
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
|
+
"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...');
|
package/src/crypto/encryption.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
126
|
+
const names = files.map(f => f.filename);
|
|
128
127
|
|
|
129
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
*
|
|
188
|
+
* Write to a file (buffers until release)
|
|
194
189
|
*/
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
return this.writeBuffers.get(filename);
|
|
198
|
-
}
|
|
190
|
+
write(filepath, fd, buffer, length, position, cb) {
|
|
191
|
+
const filename = path.basename(filepath);
|
|
199
192
|
|
|
200
|
-
|
|
201
|
-
if (!
|
|
202
|
-
|
|
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
|
|
206
|
-
const fdDisk = fs.openSync(tmpPath, 'w+');
|
|
201
|
+
const wb = this.writeBuffers.get(filename);
|
|
207
202
|
|
|
208
|
-
//
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
299
|
-
|
|
255
|
+
// Upload to Telegram
|
|
256
|
+
await this.uploadFile(filename, wb.data);
|
|
300
257
|
|
|
301
|
-
//
|
|
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('
|
|
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('
|
|
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 =
|
|
319
|
+
const cached = fileCache.get(oldName);
|
|
369
320
|
if (cached) {
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
489
|
+
if (fileCache.size >= CACHE_MAX_ENTRIES) {
|
|
515
490
|
let oldestKey = null;
|
|
516
491
|
let oldestTime = Infinity;
|
|
517
|
-
for (const [key, entry] of
|
|
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 =
|
|
499
|
+
const evicted = fileCache.get(oldestKey);
|
|
525
500
|
try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
|
|
526
|
-
|
|
501
|
+
fileCache.delete(oldestKey);
|
|
527
502
|
}
|
|
528
503
|
}
|
|
529
504
|
|
|
530
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
|
76
|
-
db.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
174
|
-
const readStream = fs.createReadStream(filePath);
|
|
165
|
+
const readStream = fs.createReadStream(filePath);
|
|
175
166
|
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
310
|
-
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
238
|
+
await pipeline(readable, writeStream);
|
|
311
239
|
|
|
312
240
|
const finalStats = fs.statSync(outputPath);
|
|
313
241
|
|
package/src/share/server.js
CHANGED
|
@@ -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 {
|
|
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 || '
|
|
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
|
-
*
|
|
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
|
|
267
|
+
async streamToResponse(fileRecord, res) {
|
|
271
268
|
const chunks = this.db.getChunks(fileRecord.id);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
356
|
-
this.
|
|
255
|
+
watcher.on('error', (err) => {
|
|
256
|
+
this.emit('watch-error', { folder: folderPath, error: err.message });
|
|
257
|
+
});
|
|
357
258
|
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
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();
|
package/src/utils/branding.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|