@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/README.md +292 -92
- package/package.json +5 -3
- package/src/cli.js +181 -23
- package/src/crypto/encryption.js +38 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +45 -47
- package/src/index.js +25 -70
- package/src/share/server.js +34 -56
- package/src/telegram/client.js +64 -16
- package/src/utils/branding.js +3 -3
- package/src/utils/chunker.js +4 -2
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/download-stream.js +87 -0
- package/src/utils/progress.js +3 -3
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
|
|
21
|
+
import os from 'os';
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
338
|
-
const
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
.
|
|
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
|
|
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
|
|
1056
|
-
if (!
|
|
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
|
|
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
|
|
package/src/crypto/encryption.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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()
|