@nightowne/tas-cli 2.1.0 → 2.3.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,14 +122,16 @@ 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));
116
136
 
117
137
  // Initialize database
@@ -152,11 +172,12 @@ program
152
172
  process.exit(1);
153
173
  }
154
174
 
155
- const config = requireConfig(DATA_DIR);
175
+ const rawConfig = requireConfig(DATA_DIR);
156
176
  spinner.stop();
157
177
 
158
178
  // Get and verify password
159
179
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
180
+ const config = resolveConfig(rawConfig, password);
160
181
 
161
182
  spinner.start('Processing file...');
162
183
 
@@ -207,7 +228,7 @@ program
207
228
  const spinner = ora('Looking up file...').start();
208
229
 
209
230
  try {
210
- const config = requireConfig(DATA_DIR);
231
+ const rawConfig = requireConfig(DATA_DIR);
211
232
 
212
233
  // Find file in index
213
234
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -223,6 +244,7 @@ program
223
244
 
224
245
  // Get and verify password
225
246
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
247
+ const config = resolveConfig(rawConfig, password);
226
248
 
227
249
  spinner.start('Downloading...');
228
250
 
@@ -266,6 +288,7 @@ program
266
288
  .alias('ls')
267
289
  .description('List all stored files')
268
290
  .option('-l, --long', 'Show detailed information')
291
+ .option('--json', 'Output as JSON (for scripting)')
269
292
  .action(async (options) => {
270
293
  try {
271
294
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -273,8 +296,15 @@ program
273
296
 
274
297
  const files = db.listAll();
275
298
 
299
+ if (options.json) {
300
+ console.log(JSON.stringify(files, null, 2));
301
+ db.close();
302
+ return;
303
+ }
304
+
276
305
  if (files.length === 0) {
277
306
  console.log(chalk.yellow('\n📭 No files stored yet. Use `tas push <file>` to upload.\n'));
307
+ db.close();
278
308
  return;
279
309
  }
280
310
 
@@ -298,6 +328,7 @@ program
298
328
  }
299
329
 
300
330
  console.log();
331
+ db.close();
301
332
 
302
333
  } catch (err) {
303
334
  console.error(chalk.red('Error listing files:'), err.message);
@@ -311,6 +342,7 @@ program
311
342
  .alias('rm')
312
343
  .description('Remove a file from the index (optionally from Telegram too)')
313
344
  .option('--hard', 'Also delete from Telegram')
345
+ .option('-p, --password <password>', 'Encryption password (required for --hard)')
314
346
  .action(async (identifier, options) => {
315
347
  try {
316
348
  const db = new FileIndex(path.join(DATA_DIR, 'index.db'));
@@ -334,8 +366,9 @@ program
334
366
  if (confirm) {
335
367
  // If hard delete, also remove from Telegram
336
368
  if (options.hard) {
337
- const configPath = path.join(DATA_DIR, 'config.json');
338
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
369
+ const rawConfig = requireConfig(DATA_DIR);
370
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
371
+ const config = resolveConfig(rawConfig, password);
339
372
 
340
373
  const client = new TelegramClient(DATA_DIR);
341
374
  await client.initialize(config.botToken);
@@ -361,11 +394,16 @@ program
361
394
  program
362
395
  .command('status')
363
396
  .description('Show TAS status and statistics')
364
- .action(async () => {
397
+ .option('--json', 'Output as JSON (for scripting)')
398
+ .action(async (options) => {
365
399
  const configPath = path.join(DATA_DIR, 'config.json');
366
400
 
367
401
  if (!fs.existsSync(configPath)) {
368
- console.log(chalk.yellow('\n⚠️ TAS not initialized. Run `tas init` first.\n'));
402
+ if (options.json) {
403
+ console.log(JSON.stringify({ initialized: false }));
404
+ } else {
405
+ console.log(chalk.yellow('\n⚠️ TAS not initialized. Run `tas init` first.\n'));
406
+ }
369
407
  return;
370
408
  }
371
409
 
@@ -378,13 +416,30 @@ program
378
416
  const storedSize = files.reduce((acc, f) => acc + f.stored_size, 0);
379
417
  const savings = totalSize > 0 ? Math.round((1 - storedSize / totalSize) * 100) : 0;
380
418
 
419
+ if (options.json) {
420
+ console.log(JSON.stringify({
421
+ initialized: true,
422
+ createdAt: config.createdAt,
423
+ username: config.username || 'unknown',
424
+ fileCount: files.length,
425
+ totalSize,
426
+ storedSize,
427
+ savingsPercent: savings,
428
+ dataDir: DATA_DIR
429
+ }, null, 2));
430
+ db.close();
431
+ return;
432
+ }
433
+
381
434
  console.log(chalk.cyan('\n📊 TAS Status\n'));
382
435
  console.log(` Initialized: ${chalk.white(new Date(config.createdAt).toLocaleDateString())}`);
383
436
  console.log(` Telegram user: ${chalk.white('@' + (config.username || 'unknown'))}`);
437
+ console.log(` Data dir: ${chalk.white(DATA_DIR)}`);
384
438
  console.log(` Files stored: ${chalk.white(files.length)}`);
385
439
  console.log(` Total size: ${chalk.white(formatBytes(totalSize))}`);
386
440
  console.log(` Compressed: ${chalk.white(formatBytes(storedSize))} ${chalk.dim(`(${savings}% saved)`)}`);
387
441
  console.log();
442
+ db.close();
388
443
  });
389
444
 
390
445
  // ============== MOUNT COMMAND ==============
@@ -395,8 +450,9 @@ program
395
450
  .action(async (mountpoint, options) => {
396
451
  console.log(chalk.cyan('\n🗂️ Mounting Telegram as filesystem...\n'));
397
452
 
398
- const config = requireConfig(DATA_DIR);
453
+ const rawConfig = requireConfig(DATA_DIR);
399
454
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
455
+ const config = resolveConfig(rawConfig, password);
400
456
 
401
457
  const spinner = ora('Initializing filesystem...').start();
402
458
 
@@ -678,8 +734,9 @@ syncCmd
678
734
  .action(async (options) => {
679
735
  console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
680
736
 
681
- const config = requireConfig(DATA_DIR);
737
+ const rawConfig = requireConfig(DATA_DIR);
682
738
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
739
+ const config = resolveConfig(rawConfig, password);
683
740
 
684
741
  let limitRate = null;
685
742
  if (options.limit) {
@@ -768,8 +825,9 @@ syncCmd
768
825
  .action(async (options) => {
769
826
  console.log(chalk.cyan('\n📥 Pulling files from Telegram...\n'));
770
827
 
771
- const config = requireConfig(DATA_DIR);
828
+ const rawConfig = requireConfig(DATA_DIR);
772
829
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
830
+ const config = resolveConfig(rawConfig, password);
773
831
 
774
832
  const spinner = ora('Loading...').start();
775
833
 
@@ -854,10 +912,13 @@ syncCmd
854
912
  program
855
913
  .command('verify')
856
914
  .description('Verify file integrity and check for missing Telegram messages')
857
- .action(async () => {
915
+ .option('-p, --password <password>', 'Encryption password')
916
+ .action(async (options) => {
858
917
  console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
859
918
 
860
- const config = requireConfig(DATA_DIR);
919
+ const rawConfig = requireConfig(DATA_DIR);
920
+ const password = await getAndVerifyPassword(options.password, DATA_DIR);
921
+ const config = resolveConfig(rawConfig, password);
861
922
 
862
923
  const spinner = ora('Checking files...').start();
863
924
 
@@ -944,10 +1005,11 @@ program
944
1005
 
945
1006
  // Helper function
946
1007
  function formatBytes(bytes) {
1008
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
947
1009
  if (bytes === 0) return '0 B';
948
1010
  const k = 1024;
949
- const sizes = ['B', 'KB', 'MB', 'GB'];
950
- const i = Math.floor(Math.log(bytes) / Math.log(k));
1011
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
1012
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
951
1013
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
952
1014
  }
953
1015
 
@@ -1052,8 +1114,8 @@ program
1052
1114
  }
1053
1115
 
1054
1116
  // Resume uploads
1055
- const config = loadConfig(DATA_DIR);
1056
- if (!config) {
1117
+ const rawConfig = loadConfig(DATA_DIR);
1118
+ if (!rawConfig) {
1057
1119
  console.log(chalk.red('✗ TAS not initialized.'));
1058
1120
  db.close();
1059
1121
  return;
@@ -1061,6 +1123,7 @@ program
1061
1123
 
1062
1124
  // Get and verify password
1063
1125
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1126
+ const config = resolveConfig(rawConfig, password);
1064
1127
 
1065
1128
  // Connect to Telegram
1066
1129
  const { TelegramClient } = await import('./telegram/client.js');
@@ -1147,8 +1210,9 @@ shareCmd
1147
1210
  .action(async (file, options) => {
1148
1211
  console.log(chalk.cyan('\n🔗 Creating share link...\n'));
1149
1212
 
1150
- const config = requireConfig(DATA_DIR);
1213
+ const rawConfig = requireConfig(DATA_DIR);
1151
1214
  const password = await getAndVerifyPassword(options.password, DATA_DIR);
1215
+ const config = resolveConfig(rawConfig, password);
1152
1216
 
1153
1217
  const spinner = ora('Setting up...').start();
1154
1218
 
@@ -1299,6 +1363,98 @@ shareCmd
1299
1363
  }
1300
1364
  });
1301
1365
 
1366
+ // ============== DOCTOR COMMAND ==============
1367
+ program
1368
+ .command('doctor')
1369
+ .description('🩺 Run self-diagnostics and check system health')
1370
+ .action(async () => {
1371
+ console.log(chalk.cyan('\n🩺 TAS Doctor — System Health Check\n'));
1372
+
1373
+ const checks = [];
1374
+ const ok = (label) => { checks.push({ label, status: 'ok' }); console.log(chalk.green(` ✓ ${label}`)); };
1375
+ const warn = (label, detail) => { checks.push({ label, status: 'warn', detail }); console.log(chalk.yellow(` ⚠ ${label}`) + chalk.dim(` — ${detail}`)); };
1376
+ const fail = (label, detail) => { checks.push({ label, status: 'fail', detail }); console.log(chalk.red(` ✗ ${label}`) + chalk.dim(` — ${detail}`)); };
1377
+
1378
+ // 1. Check Node.js version
1379
+ const nodeVer = process.versions.node;
1380
+ const major = parseInt(nodeVer.split('.')[0]);
1381
+ if (major >= 18) ok(`Node.js ${nodeVer}`);
1382
+ else warn(`Node.js ${nodeVer}`, 'Requires >= 18.0.0');
1383
+
1384
+ // 2. Check data directory
1385
+ if (fs.existsSync(DATA_DIR)) ok(`Data directory: ${DATA_DIR}`);
1386
+ else warn('Data directory missing', `Run \`tas init\` to create ${DATA_DIR}`);
1387
+
1388
+ // 3. Check config
1389
+ const configPath = path.join(DATA_DIR, 'config.json');
1390
+ if (fs.existsSync(configPath)) {
1391
+ try {
1392
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
1393
+ if (config.configVersion === 2) ok('Config v2 (encrypted token)');
1394
+ else if (config.botToken) warn('Config v1 (plaintext token)', 'Re-run `tas init` to encrypt token');
1395
+ else fail('Config invalid', 'Missing bot token');
1396
+
1397
+ if (config.chatId) ok(`Chat ID: ${config.chatId}`);
1398
+ else fail('Chat ID missing', 'Run `tas init`');
1399
+ } catch (e) {
1400
+ fail('Config corrupted', e.message);
1401
+ }
1402
+ } else {
1403
+ warn('Config not found', 'Run `tas init`');
1404
+ }
1405
+
1406
+ // 4. Check database
1407
+ const dbPath = path.join(DATA_DIR, 'index.db');
1408
+ if (fs.existsSync(dbPath)) {
1409
+ try {
1410
+ const db = new FileIndex(dbPath);
1411
+ db.init();
1412
+ const stats = db.getStats();
1413
+ ok(`Database: ${stats.file_count} files, ${formatBytes(stats.total_original)} total`);
1414
+ db.close();
1415
+ } catch (e) {
1416
+ fail('Database error', e.message);
1417
+ }
1418
+ } else {
1419
+ warn('Database not found', 'Will be created on first upload');
1420
+ }
1421
+
1422
+ // 5. Check FUSE availability
1423
+ try {
1424
+ await import('fuse-native');
1425
+ ok('FUSE support available');
1426
+ } catch (e) {
1427
+ warn('FUSE not available', 'Install libfuse for mount support');
1428
+ }
1429
+
1430
+ // 6. Check disk space
1431
+ try {
1432
+ const { execSync } = await import('child_process');
1433
+ const df = execSync(`df -h "${DATA_DIR}" 2>/dev/null || echo "unknown"`).toString().trim();
1434
+ const lines = df.split('\n');
1435
+ if (lines.length > 1) {
1436
+ const parts = lines[1].split(/\s+/);
1437
+ const avail = parts[3] || 'unknown';
1438
+ const usage = parts[4] || 'unknown';
1439
+ if (parseInt(usage) > 90) warn(`Disk space: ${avail} free (${usage} used)`, 'Running low!');
1440
+ else ok(`Disk space: ${avail} free (${usage} used)`);
1441
+ }
1442
+ } catch (e) { /* ignore */ }
1443
+
1444
+ // 7. Security check
1445
+ const iterations = 600000;
1446
+ ok(`Encryption: AES-256-GCM, PBKDF2-SHA512 ${iterations.toLocaleString()} iterations`);
1447
+
1448
+ // Summary
1449
+ const fails = checks.filter(c => c.status === 'fail').length;
1450
+ const warns = checks.filter(c => c.status === 'warn').length;
1451
+ console.log();
1452
+ if (fails > 0) console.log(chalk.red(` ${fails} issue(s) found. Please fix them above.`));
1453
+ else if (warns > 0) console.log(chalk.yellow(` ${warns} warning(s). System is functional.`));
1454
+ else console.log(chalk.green(' ✨ All systems go! TAS is healthy.'));
1455
+ console.log();
1456
+ });
1457
+
1302
1458
  program.parse();
1303
1459
 
1304
1460
 
@@ -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,29 @@ 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
+ */
32
+ static verifyPasswordHash(password, storedHash) {
33
+ const encryptor = new Encryptor(password);
34
+
35
+ // Try new PBKDF2-based verification first
36
+ if (encryptor.getPasswordHash() === storedHash) {
37
+ return true;
38
+ }
39
+
40
+ // Fallback: legacy SHA-256 verification for backward compatibility
41
+ const legacyHash = crypto.createHash('sha256')
42
+ .update(password + 'was-verify')
26
43
  .digest('hex');
44
+ return legacyHash === storedHash;
27
45
  }
28
46
 
29
47
  /**
@@ -92,6 +110,10 @@ export class Encryptor {
92
110
  callback();
93
111
  },
94
112
  flush(callback) {
113
+ if (!headerWritten) {
114
+ this.push(Buffer.concat([salt, iv]));
115
+ headerWritten = true;
116
+ }
95
117
  const final = cipher.final();
96
118
  if (final.length > 0) {
97
119
  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
  /**