@nightowne/tas-cli 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -15,13 +15,31 @@ import { Compressor } from './utils/compression.js';
15
15
  import { FileIndex } from './db/index.js';
16
16
  import { processFile, retrieveFile } from './index.js';
17
17
  import { printBanner, LOGO, TAGLINE, VERSION } from './utils/branding.js';
18
- import { getPassword, verifyPassword, loadConfig, requireConfig, getAndVerifyPassword } from './utils/cli-helpers.js';
18
+ import { getPassword, verifyPassword, loadConfig, requireConfig, getAndVerifyPassword, decryptBotToken, resolveConfig } from './utils/cli-helpers.js';
19
19
  import fs from 'fs';
20
20
  import path from 'path';
21
- import { fileURLToPath } from 'url';
21
+ import os from 'os';
22
22
 
23
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
- const DATA_DIR = path.join(__dirname, '..', 'data');
23
+ const DATA_DIR = process.env.TAS_DATA_DIR || path.join(os.homedir(), '.tas');
24
+
25
+ // Global error handlers — prevent silent crashes
26
+ process.on('unhandledRejection', (reason) => {
27
+ console.error(chalk.red('\n✗ Unhandled error:'), reason?.message || reason);
28
+ process.exit(1);
29
+ });
30
+
31
+ process.on('uncaughtException', (err) => {
32
+ console.error(chalk.red('\n✗ Fatal error:'), err.message);
33
+ process.exit(1);
34
+ });
35
+
36
+ // Graceful shutdown on signals
37
+ const cleanupAndExit = (signal) => {
38
+ console.log(chalk.dim(`\n${signal} received, shutting down...`));
39
+ process.exit(0);
40
+ };
41
+ process.on('SIGINT', () => cleanupAndExit('SIGINT'));
42
+ process.on('SIGTERM', () => cleanupAndExit('SIGTERM'));
25
43
 
26
44
  const program = new Command();
27
45
 
@@ -104,15 +122,19 @@ program
104
122
  const userInfo = await client.waitForChatId(120000);
105
123
  spinner.succeed(`Linked to ${userInfo.firstName} (@${userInfo.username})`);
106
124
 
107
- // Save config
125
+ // Save config (bot token encrypted with user's password)
108
126
  const configPath = path.join(DATA_DIR, 'config.json');
127
+ const encryptedToken = encryptor.encrypt(Buffer.from(token, 'utf-8')).toString('base64');
109
128
  fs.writeFileSync(configPath, JSON.stringify({
110
- botToken: token,
129
+ encryptedBotToken: encryptedToken,
111
130
  chatId: userInfo.chatId,
112
131
  passwordHash: encryptor.getPasswordHash(),
113
132
  username: userInfo.username,
114
- createdAt: new Date().toISOString()
133
+ createdAt: new Date().toISOString(),
134
+ configVersion: 2
115
135
  }, null, 2));
136
+ // Restrict config file permissions — contains encrypted token and password hash
137
+ try { fs.chmodSync(configPath, 0o600); } catch { /* ignore on Windows */ }
116
138
 
117
139
  // Initialize database
118
140
  spinner.start('Initializing local index...');
@@ -152,11 +174,12 @@ program
152
174
  process.exit(1);
153
175
  }
154
176
 
155
- const config = requireConfig(DATA_DIR);
177
+ const rawConfig = requireConfig(DATA_DIR);
156
178
  spinner.stop();
157
179
 
158
180
  // Get and verify password
159
181
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
182
+ const config = resolveConfig(rawConfig, password);
160
183
 
161
184
  spinner.start('Processing file...');
162
185
 
@@ -207,7 +230,7 @@ program
207
230
  const spinner = ora('Looking up file...').start();
208
231
 
209
232
  try {
210
- const config = requireConfig(DATA_DIR);
233
+ const rawConfig = requireConfig(DATA_DIR);
211
234
 
212
235
  // Find file in index
213
236
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -223,6 +246,7 @@ program
223
246
 
224
247
  // Get and verify password
225
248
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
249
+ const config = resolveConfig(rawConfig, password);
226
250
 
227
251
  spinner.start('Downloading...');
228
252
 
@@ -266,6 +290,7 @@ program
266
290
  .alias('ls')
267
291
  .description('List all stored files')
268
292
  .option('-l, --long', 'Show detailed information')
293
+ .option('--json', 'Output as JSON (for scripting)')
269
294
  .action(async (options) => {
270
295
  try {
271
296
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -273,8 +298,15 @@ program
273
298
 
274
299
  const files = db.listAll();
275
300
 
301
+ if (options.json) {
302
+ console.log(JSON.stringify(files, null, 2));
303
+ db.close();
304
+ return;
305
+ }
306
+
276
307
  if (files.length === 0) {
277
308
  console.log(chalk.yellow('\n📭 No files stored yet. Use `tas push <file>` to upload.\n'));
309
+ db.close();
278
310
  return;
279
311
  }
280
312
 
@@ -298,6 +330,7 @@ program
298
330
  }
299
331
 
300
332
  console.log();
333
+ db.close();
301
334
 
302
335
  } catch (err) {
303
336
  console.error(chalk.red('Error listing files:'), err.message);
@@ -311,6 +344,7 @@ program
311
344
  .alias('rm')
312
345
  .description('Remove a file from the index (optionally from Telegram too)')
313
346
  .option('--hard', 'Also delete from Telegram')
347
+ .option('-p, --password <password>', 'Encryption password (required for --hard)')
314
348
  .action(async (identifier, options) => {
315
349
  try {
316
350
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -334,8 +368,9 @@ program
334
368
  if (confirm) {
335
369
  // If hard delete, also remove from Telegram
336
370
  if (options.hard) {
337
- const configPath = path.join(DATA_DIR, 'config.json');
338
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
371
+ const rawConfig = requireConfig(DATA_DIR);
372
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
373
+ const config = resolveConfig(rawConfig, password);
339
374
 
340
375
  const client = new TelegramClient(DATA_DIR);
341
376
  await client.initialize(config.botToken);
@@ -361,11 +396,16 @@ program
361
396
  program
362
397
  .command('status')
363
398
  .description('Show TAS status and statistics')
364
- .action(async () => {
399
+ .option('--json', 'Output as JSON (for scripting)')
400
+ .action(async (options) => {
365
401
  const configPath = path.join(DATA_DIR, 'config.json');
366
402
 
367
403
  if (!fs.existsSync(configPath)) {
368
- console.log(chalk.yellow('\n⚠️ TAS not initialized. Run `tas init` first.\n'));
404
+ if (options.json) {
405
+ console.log(JSON.stringify({ initialized: false }));
406
+ } else {
407
+ console.log(chalk.yellow('\n⚠️ TAS not initialized. Run `tas init` first.\n'));
408
+ }
369
409
  return;
370
410
  }
371
411
 
@@ -378,13 +418,30 @@ program
378
418
  const storedSize = files.reduce((acc, f) => acc + f.stored_size, 0);
379
419
  const savings = totalSize > 0 ? Math.round((1 - storedSize / totalSize) * 100) : 0;
380
420
 
421
+ if (options.json) {
422
+ console.log(JSON.stringify({
423
+ initialized: true,
424
+ createdAt: config.createdAt,
425
+ username: config.username || 'unknown',
426
+ fileCount: files.length,
427
+ totalSize,
428
+ storedSize,
429
+ savingsPercent: savings,
430
+ dataDir: DATA_DIR
431
+ }, null, 2));
432
+ db.close();
433
+ return;
434
+ }
435
+
381
436
  console.log(chalk.cyan('\n📊 TAS Status\n'));
382
437
  console.log(` Initialized: ${chalk.white(new Date(config.createdAt).toLocaleDateString())}`);
383
438
  console.log(` Telegram user: ${chalk.white('@' + (config.username || 'unknown'))}`);
439
+ console.log(` Data dir: ${chalk.white(DATA_DIR)}`);
384
440
  console.log(` Files stored: ${chalk.white(files.length)}`);
385
441
  console.log(` Total size: ${chalk.white(formatBytes(totalSize))}`);
386
442
  console.log(` Compressed: ${chalk.white(formatBytes(storedSize))} ${chalk.dim(`(${savings}% saved)`)}`);
387
443
  console.log();
444
+ db.close();
388
445
  });
389
446
 
390
447
  // ============== MOUNT COMMAND ==============
@@ -395,8 +452,9 @@ program
395
452
  .action(async (mountpoint, options) => {
396
453
  console.log(chalk.cyan('\n🗂️ Mounting Telegram as filesystem...\n'));
397
454
 
398
- const config = requireConfig(DATA_DIR);
455
+ const rawConfig = requireConfig(DATA_DIR);
399
456
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
457
+ const config = resolveConfig(rawConfig, password);
400
458
 
401
459
  const spinner = ora('Initializing filesystem...').start();
402
460
 
@@ -678,8 +736,9 @@ syncCmd
678
736
  .action(async (options) => {
679
737
  console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
680
738
 
681
- const config = requireConfig(DATA_DIR);
739
+ const rawConfig = requireConfig(DATA_DIR);
682
740
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
741
+ const config = resolveConfig(rawConfig, password);
683
742
 
684
743
  let limitRate = null;
685
744
  if (options.limit) {
@@ -768,8 +827,9 @@ syncCmd
768
827
  .action(async (options) => {
769
828
  console.log(chalk.cyan('\n📥 Pulling files from Telegram...\n'));
770
829
 
771
- const config = requireConfig(DATA_DIR);
830
+ const rawConfig = requireConfig(DATA_DIR);
772
831
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
832
+ const config = resolveConfig(rawConfig, password);
773
833
 
774
834
  const spinner = ora('Loading...').start();
775
835
 
@@ -854,10 +914,13 @@ syncCmd
854
914
  program
855
915
  .command('verify')
856
916
  .description('Verify file integrity and check for missing Telegram messages')
857
- .action(async () => {
917
+ .option('-p, --password <password>', 'Encryption password')
918
+ .action(async (options) => {
858
919
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
859
920
 
860
- const config = requireConfig(DATA_DIR);
921
+ const rawConfig = requireConfig(DATA_DIR);
922
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
923
+ const config = resolveConfig(rawConfig, password);
861
924
 
862
925
  const spinner = ora('Checking files...').start();
863
926
 
@@ -944,10 +1007,11 @@ program
944
1007
 
945
1008
  // Helper function
946
1009
  function formatBytes(bytes) {
1010
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
947
1011
  if (bytes === 0) return '0 B';
948
1012
  const k = 1024;
949
- const sizes = ['B', 'KB', 'MB', 'GB'];
950
- const i = Math.floor(Math.log(bytes) / Math.log(k));
1013
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1014
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
951
1015
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
952
1016
  }
953
1017
 
@@ -1052,8 +1116,8 @@ program
1052
1116
  }
1053
1117
 
1054
1118
  // Resume uploads
1055
- const config = loadConfig(DATA_DIR);
1056
- if (!config) {
1119
+ const rawConfig = loadConfig(DATA_DIR);
1120
+ if (!rawConfig) {
1057
1121
  console.log(chalk.red('✗ TAS not initialized.'));
1058
1122
  db.close();
1059
1123
  return;
@@ -1061,6 +1125,7 @@ program
1061
1125
 
1062
1126
  // Get and verify password
1063
1127
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1128
+ const config = resolveConfig(rawConfig, password);
1064
1129
 
1065
1130
  // Connect to Telegram
1066
1131
  const { TelegramClient } = await import('./telegram/client.js');
@@ -1147,8 +1212,9 @@ shareCmd
1147
1212
  .action(async (file, options) => {
1148
1213
  console.log(chalk.cyan('\n🔗 Creating share link...\n'));
1149
1214
 
1150
- const config = requireConfig(DATA_DIR);
1215
+ const rawConfig = requireConfig(DATA_DIR);
1151
1216
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1217
+ const config = resolveConfig(rawConfig, password);
1152
1218
 
1153
1219
  const spinner = ora('Setting up...').start();
1154
1220
 
@@ -1299,6 +1365,98 @@ shareCmd
1299
1365
  }
1300
1366
  });
1301
1367
 
1368
+ // ============== DOCTOR COMMAND ==============
1369
+ program
1370
+ .command('doctor')
1371
+ .description('🩺 Run self-diagnostics and check system health')
1372
+ .action(async () => {
1373
+ console.log(chalk.cyan('\n🩺 TAS Doctor — System Health Check\n'));
1374
+
1375
+ const checks = [];
1376
+ const ok = (label) => { checks.push({ label, status: 'ok' }); console.log(chalk.green(` ✓ ${label}`)); };
1377
+ const warn = (label, detail) => { checks.push({ label, status: 'warn', detail }); console.log(chalk.yellow(` ⚠ ${label}`) + chalk.dim(` — ${detail}`)); };
1378
+ const fail = (label, detail) => { checks.push({ label, status: 'fail', detail }); console.log(chalk.red(` ✗ ${label}`) + chalk.dim(` — ${detail}`)); };
1379
+
1380
+ // 1. Check Node.js version
1381
+ const nodeVer = process.versions.node;
1382
+ const major = parseInt(nodeVer.split('.')[0]);
1383
+ if (major >= 18) ok(`Node.js ${nodeVer}`);
1384
+ else warn(`Node.js ${nodeVer}`, 'Requires >= 18.0.0');
1385
+
1386
+ // 2. Check data directory
1387
+ if (fs.existsSync(DATA_DIR)) ok(`Data directory: ${DATA_DIR}`);
1388
+ else warn('Data directory missing', `Run \`tas init\` to create ${DATA_DIR}`);
1389
+
1390
+ // 3. Check config
1391
+ const configPath = path.join(DATA_DIR, 'config.json');
1392
+ if (fs.existsSync(configPath)) {
1393
+ try {
1394
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1395
+ if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1396
+ else if (config.botToken) warn('Config v1 (plaintext token)', 'Re-run `tas init` to encrypt token');
1397
+ else fail('Config invalid', 'Missing bot token');
1398
+
1399
+ if (config.chatId) ok(`Chat ID: ${config.chatId}`);
1400
+ else fail('Chat ID missing', 'Run `tas init`');
1401
+ } catch (e) {
1402
+ fail('Config corrupted', e.message);
1403
+ }
1404
+ } else {
1405
+ warn('Config not found', 'Run `tas init`');
1406
+ }
1407
+
1408
+ // 4. Check database
1409
+ const dbPath = path.join(DATA_DIR, 'index.db');
1410
+ if (fs.existsSync(dbPath)) {
1411
+ try {
1412
+ const db = new FileIndex(dbPath);
1413
+ db.init();
1414
+ const stats = db.getStats();
1415
+ ok(`Database: ${stats.file_count} files, ${formatBytes(stats.total_original)} total`);
1416
+ db.close();
1417
+ } catch (e) {
1418
+ fail('Database error', e.message);
1419
+ }
1420
+ } else {
1421
+ warn('Database not found', 'Will be created on first upload');
1422
+ }
1423
+
1424
+ // 5. Check FUSE availability
1425
+ try {
1426
+ await import('fuse-native');
1427
+ ok('FUSE support available');
1428
+ } catch (e) {
1429
+ warn('FUSE not available', 'Install libfuse for mount support');
1430
+ }
1431
+
1432
+ // 6. Check disk space
1433
+ try {
1434
+ const { execSync } = await import('child_process');
1435
+ const df = execSync(`df -h "${DATA_DIR}" 2>/dev/null || echo "unknown"`).toString().trim();
1436
+ const lines = df.split('\n');
1437
+ if (lines.length > 1) {
1438
+ const parts = lines[1].split(/\s+/);
1439
+ const avail = parts[3] || 'unknown';
1440
+ const usage = parts[4] || 'unknown';
1441
+ if (parseInt(usage) > 90) warn(`Disk space: ${avail} free (${usage} used)`, 'Running low!');
1442
+ else ok(`Disk space: ${avail} free (${usage} used)`);
1443
+ }
1444
+ } catch (e) { /* ignore */ }
1445
+
1446
+ // 7. Security check
1447
+ const iterations = 600000;
1448
+ ok(`Encryption: AES-256-GCM, PBKDF2-SHA512 ${iterations.toLocaleString()} iterations`);
1449
+
1450
+ // Summary
1451
+ const fails = checks.filter(c => c.status === 'fail').length;
1452
+ const warns = checks.filter(c => c.status === 'warn').length;
1453
+ console.log();
1454
+ if (fails > 0) console.log(chalk.red(` ${fails} issue(s) found. Please fix them above.`));
1455
+ else if (warns > 0) console.log(chalk.yellow(` ${warns} warning(s). System is functional.`));
1456
+ else console.log(chalk.green(' ✨ All systems go! TAS is healthy.'));
1457
+ console.log();
1458
+ });
1459
+
1302
1460
  program.parse();
1303
1461
 
1304
1462
 
@@ -10,7 +10,7 @@ const KEY_LENGTH = 32; // 256 bits
10
10
  const IV_LENGTH = 12; // 96 bits for GCM
11
11
  const TAG_LENGTH = 16; // 128 bits auth tag
12
12
  const SALT_LENGTH = 32;
13
- const PBKDF2_ITERATIONS = 100000;
13
+ const PBKDF2_ITERATIONS = 600000; // OWASP 2025 recommendation for SHA-512
14
14
 
15
15
  export class Encryptor {
16
16
  constructor(password) {
@@ -19,11 +19,42 @@ export class Encryptor {
19
19
 
20
20
  /**
21
21
  * Get a hash of the password for verification (not the actual key!)
22
+ * Uses PBKDF2 with a fixed salt derived from the password domain to make brute-force expensive.
22
23
  */
23
24
  getPasswordHash() {
24
- return crypto.createHash('sha256')
25
- .update(this.password + 'was-verify')
25
+ const verifySalt = Buffer.from('tas-password-verify-v2', 'utf-8');
26
+ return crypto.pbkdf2Sync(this.password, verifySalt, PBKDF2_ITERATIONS, 32, 'sha512').toString('hex');
27
+ }
28
+
29
+ /**
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.
32
+ */
33
+ static verifyPasswordHash(password, storedHash) {
34
+ const encryptor = new Encryptor(password);
35
+
36
+ const computedHash = encryptor.getPasswordHash();
37
+ const computedBuf = Buffer.from(computedHash, 'utf-8');
38
+ const storedBuf = Buffer.from(storedHash, 'utf-8');
39
+
40
+ // Try new PBKDF2-based verification first
41
+ if (computedBuf.length === storedBuf.length &&
42
+ crypto.timingSafeEqual(computedBuf, storedBuf)) {
43
+ return true;
44
+ }
45
+
46
+ // Fallback: legacy SHA-256 verification for backward compatibility
47
+ const legacyHash = crypto.createHash('sha256')
48
+ .update(password + 'was-verify')
26
49
  .digest('hex');
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;
27
58
  }
28
59
 
29
60
  /**
@@ -92,6 +123,10 @@ export class Encryptor {
92
123
  callback();
93
124
  },
94
125
  flush(callback) {
126
+ if (!headerWritten) {
127
+ this.push(Buffer.concat([salt, iv]));
128
+ headerWritten = true;
129
+ }
95
130
  const final = cipher.final();
96
131
  if (final.length > 0) {
97
132
  this.push(final);
package/src/db/index.js CHANGED
@@ -171,25 +171,32 @@ export class FileIndex {
171
171
  }
172
172
 
173
173
  /**
174
- * Find file by hash
174
+ * Escape SQL LIKE wildcard characters
175
+ */
176
+ _escapeLike(str) {
177
+ return str.replace(/[%_\\]/g, '\\$&');
178
+ }
179
+
180
+ /**
181
+ * Find file by hash (exact match or prefix match)
175
182
  */
176
183
  findByHash(hash) {
177
184
  const stmt = this.db.prepare(`
178
- SELECT * FROM files WHERE hash = ? OR hash LIKE ?
185
+ SELECT * FROM files WHERE hash = ? OR hash LIKE ? ESCAPE '\\'
179
186
  `);
180
187
 
181
- return stmt.get(hash, hash + '%');
188
+ return stmt.get(hash, this._escapeLike(hash) + '%');
182
189
  }
183
190
 
184
191
  /**
185
- * Find file by filename
192
+ * Find file by filename (exact match or substring match)
186
193
  */
187
194
  findByName(filename) {
188
195
  const stmt = this.db.prepare(`
189
- SELECT * FROM files WHERE filename = ? OR filename LIKE ?
196
+ SELECT * FROM files WHERE filename = ? OR filename LIKE ? ESCAPE '\\'
190
197
  `);
191
198
 
192
- return stmt.get(filename, '%' + filename + '%');
199
+ return stmt.get(filename, '%' + this._escapeLike(filename) + '%');
193
200
  }
194
201
 
195
202
  /**
@@ -399,11 +406,11 @@ export class FileIndex {
399
406
  SELECT f.*, GROUP_CONCAT(t.tag) as tags
400
407
  FROM files f
401
408
  LEFT JOIN tags t ON f.id = t.file_id
402
- WHERE f.filename LIKE ?
409
+ WHERE f.filename LIKE ? ESCAPE '\\'
403
410
  GROUP BY f.id
404
411
  ORDER BY f.created_at DESC
405
412
  `);
406
- return stmt.all(`%${query}%`);
413
+ return stmt.all(`%${this._escapeLike(query)}%`);
407
414
  }
408
415
 
409
416
  /**
@@ -414,11 +421,11 @@ export class FileIndex {
414
421
  SELECT f.*, GROUP_CONCAT(t.tag) as tags
415
422
  FROM files f
416
423
  INNER JOIN tags t ON f.id = t.file_id
417
- WHERE t.tag LIKE ?
424
+ WHERE t.tag LIKE ? ESCAPE '\\'
418
425
  GROUP BY f.id
419
426
  ORDER BY f.created_at DESC
420
427
  `);
421
- return stmt.all(`%${query}%`);
428
+ return stmt.all(`%${this._escapeLike(query)}%`);
422
429
  }
423
430
 
424
431
  // ============== RESUME UPLOAD METHODS ==============
@@ -572,10 +579,9 @@ export class FileIndex {
572
579
  */
573
580
  cleanExpiredShares() {
574
581
  const stmt = this.db.prepare(`
575
- DELETE FROM shares WHERE
576
- REPLACE(REPLACE(expires_at, 'T', ' '), 'Z', '') < strftime('%Y-%m-%d %H:%M:%f', 'now')
582
+ DELETE FROM shares WHERE expires_at < ?
577
583
  `);
578
- return stmt.run().changes;
584
+ return stmt.run(new Date().toISOString()).changes;
579
585
  }
580
586
 
581
587
  /**
package/src/fuse/mount.js CHANGED
@@ -5,21 +5,40 @@
5
5
  * This is the killer feature - use Telegram like a regular folder!
6
6
  */
7
7
 
8
- import Fuse from 'fuse-native';
9
8
  import path from 'path';
10
9
  import fs from 'fs';
10
+ import { pipeline } from 'stream/promises';
11
+
12
+ let Fuse;
13
+ try {
14
+ Fuse = (await import('fuse-native')).default;
15
+ } catch {
16
+ // fuse-native is optional — unavailable on ARM64 or systems without libfuse
17
+ }
11
18
  import { TelegramClient } from '../telegram/client.js';
12
19
  import { Encryptor } from '../crypto/encryption.js';
13
20
  import { Compressor } from '../utils/compression.js';
14
21
  import { FileIndex } from '../db/index.js';
15
- import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
22
+ import { createHeader } from '../utils/chunker.js';
23
+ import { createDownloadPipeline } from '../utils/download-stream.js';
16
24
 
17
25
  // File cache for performance (avoid re-downloading)
18
26
  const fileCache = new Map();
19
27
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
28
+ const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
20
29
 
21
30
  export class TelegramFS {
22
31
  constructor(options) {
32
+ if (!Fuse) {
33
+ throw new Error(
34
+ 'fuse-native is not available on this system.\n' +
35
+ ' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
36
+ ' On macOS: brew install macfuse && npm install fuse-native\n' +
37
+ ' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
38
+ ' All other TAS commands (push, pull, sync, share) work without FUSE.'
39
+ );
40
+ }
41
+
23
42
  this.dataDir = options.dataDir;
24
43
  this.password = options.password;
25
44
  this.config = options.config;
@@ -364,56 +383,18 @@ export class TelegramFS {
364
383
  }
365
384
 
366
385
  const chunks = this.db.getChunks(file.id);
367
- if (chunks.length === 0) throw new Error('No chunks found');
368
-
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
- }
386
+
387
+ const { readable } = await createDownloadPipeline({
388
+ client: this.client,
389
+ chunks,
390
+ encryptor: this.encryptor,
391
+ compressor: this.compressor
410
392
  });
411
393
 
412
394
  const tmpOutputPath = outputPath + '.tmp';
413
395
  const writeStream = fs.createWriteStream(tmpOutputPath);
414
396
 
415
- // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
416
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
397
+ await pipeline(readable, writeStream);
417
398
 
418
399
  // Rename to final atomic path
419
400
  fs.renameSync(tmpOutputPath, outputPath);
@@ -504,6 +485,23 @@ export class TelegramFS {
504
485
  }
505
486
 
506
487
  setCache(filename, cachePath) {
488
+ // Evict oldest entry if cache is full
489
+ if (fileCache.size >= CACHE_MAX_ENTRIES) {
490
+ let oldestKey = null;
491
+ let oldestTime = Infinity;
492
+ for (const [key, entry] of fileCache) {
493
+ if (entry.timestamp < oldestTime) {
494
+ oldestTime = entry.timestamp;
495
+ oldestKey = key;
496
+ }
497
+ }
498
+ if (oldestKey) {
499
+ const evicted = fileCache.get(oldestKey);
500
+ try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
501
+ fileCache.delete(oldestKey);
502
+ }
503
+ }
504
+
507
505
  fileCache.set(filename, {
508
506
  path: cachePath,
509
507
  timestamp: Date.now()