@nightowne/tas-cli 2.0.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/README.md +326 -132
- package/package.json +4 -2
- package/src/cli.js +199 -24
- package/src/crypto/encryption.js +153 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +253 -155
- package/src/index.js +209 -112
- package/src/share/server.js +100 -32
- package/src/sync/sync.js +211 -56
- package/src/telegram/client.js +102 -26
- package/src/utils/branding.js +10 -3
- package/src/utils/chunker.js +15 -3
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/compression.js +30 -0
- package/src/utils/progress.js +3 -3
- package/src/utils/throttle.js +26 -0
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,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
|
-
|
|
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
|
|
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
|
|
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
|
|
338
|
-
const
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -674,11 +730,30 @@ syncCmd
|
|
|
674
730
|
.command('start')
|
|
675
731
|
.description('Start syncing all registered folders')
|
|
676
732
|
.option('-p, --password <password>', 'Encryption password (uses TAS_PASSWORD env var if not provided)')
|
|
733
|
+
.option('-l, --limit <limit>', 'Bandwidth limit (e.g. 500k, 1m)')
|
|
677
734
|
.action(async (options) => {
|
|
678
735
|
console.log(chalk.cyan('\n🔄 Starting folder sync...\n'));
|
|
679
736
|
|
|
680
|
-
const
|
|
737
|
+
const rawConfig = requireConfig(DATA_DIR);
|
|
681
738
|
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
739
|
+
const config = resolveConfig(rawConfig, password);
|
|
740
|
+
|
|
741
|
+
let limitRate = null;
|
|
742
|
+
if (options.limit) {
|
|
743
|
+
const match = options.limit.match(/^(\d+)([kmg]?)$/i);
|
|
744
|
+
if (!match) {
|
|
745
|
+
console.error(chalk.red('Invalid limit format. Use e.g. 500{}, 1m'));
|
|
746
|
+
process.exit(1);
|
|
747
|
+
}
|
|
748
|
+
const val = parseInt(match[1]);
|
|
749
|
+
const unit = match[2].toLowerCase();
|
|
750
|
+
if (unit === 'k') limitRate = val * 1024;
|
|
751
|
+
else if (unit === 'm') limitRate = val * 1024 * 1024;
|
|
752
|
+
else if (unit === 'g') limitRate = val * 1024 * 1024 * 1024;
|
|
753
|
+
else limitRate = val;
|
|
754
|
+
|
|
755
|
+
console.log(chalk.dim(` Bandwidth limit: ${options.limit}/s`));
|
|
756
|
+
}
|
|
682
757
|
|
|
683
758
|
try {
|
|
684
759
|
const { SyncEngine } = await import('./sync/sync.js');
|
|
@@ -686,7 +761,8 @@ syncCmd
|
|
|
686
761
|
const syncEngine = new SyncEngine({
|
|
687
762
|
dataDir: DATA_DIR,
|
|
688
763
|
password,
|
|
689
|
-
config
|
|
764
|
+
config,
|
|
765
|
+
limitRate
|
|
690
766
|
});
|
|
691
767
|
|
|
692
768
|
await syncEngine.initialize();
|
|
@@ -749,8 +825,9 @@ syncCmd
|
|
|
749
825
|
.action(async (options) => {
|
|
750
826
|
console.log(chalk.cyan('\n📥 Pulling files from Telegram...\n'));
|
|
751
827
|
|
|
752
|
-
const
|
|
828
|
+
const rawConfig = requireConfig(DATA_DIR);
|
|
753
829
|
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
830
|
+
const config = resolveConfig(rawConfig, password);
|
|
754
831
|
|
|
755
832
|
const spinner = ora('Loading...').start();
|
|
756
833
|
|
|
@@ -835,10 +912,13 @@ syncCmd
|
|
|
835
912
|
program
|
|
836
913
|
.command('verify')
|
|
837
914
|
.description('Verify file integrity and check for missing Telegram messages')
|
|
838
|
-
.
|
|
915
|
+
.option('-p, --password <password>', 'Encryption password')
|
|
916
|
+
.action(async (options) => {
|
|
839
917
|
console.log(chalk.cyan('\n🔍 Verifying file integrity...\n'));
|
|
840
918
|
|
|
841
|
-
const
|
|
919
|
+
const rawConfig = requireConfig(DATA_DIR);
|
|
920
|
+
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
921
|
+
const config = resolveConfig(rawConfig, password);
|
|
842
922
|
|
|
843
923
|
const spinner = ora('Checking files...').start();
|
|
844
924
|
|
|
@@ -925,10 +1005,11 @@ program
|
|
|
925
1005
|
|
|
926
1006
|
// Helper function
|
|
927
1007
|
function formatBytes(bytes) {
|
|
1008
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
|
|
928
1009
|
if (bytes === 0) return '0 B';
|
|
929
1010
|
const k = 1024;
|
|
930
|
-
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
931
|
-
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);
|
|
932
1013
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
933
1014
|
}
|
|
934
1015
|
|
|
@@ -1033,8 +1114,8 @@ program
|
|
|
1033
1114
|
}
|
|
1034
1115
|
|
|
1035
1116
|
// Resume uploads
|
|
1036
|
-
const
|
|
1037
|
-
if (!
|
|
1117
|
+
const rawConfig = loadConfig(DATA_DIR);
|
|
1118
|
+
if (!rawConfig) {
|
|
1038
1119
|
console.log(chalk.red('✗ TAS not initialized.'));
|
|
1039
1120
|
db.close();
|
|
1040
1121
|
return;
|
|
@@ -1042,6 +1123,7 @@ program
|
|
|
1042
1123
|
|
|
1043
1124
|
// Get and verify password
|
|
1044
1125
|
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
1126
|
+
const config = resolveConfig(rawConfig, password);
|
|
1045
1127
|
|
|
1046
1128
|
// Connect to Telegram
|
|
1047
1129
|
const { TelegramClient } = await import('./telegram/client.js');
|
|
@@ -1128,8 +1210,9 @@ shareCmd
|
|
|
1128
1210
|
.action(async (file, options) => {
|
|
1129
1211
|
console.log(chalk.cyan('\n🔗 Creating share link...\n'));
|
|
1130
1212
|
|
|
1131
|
-
const
|
|
1213
|
+
const rawConfig = requireConfig(DATA_DIR);
|
|
1132
1214
|
const password = await getAndVerifyPassword(options.password, DATA_DIR);
|
|
1215
|
+
const config = resolveConfig(rawConfig, password);
|
|
1133
1216
|
|
|
1134
1217
|
const spinner = ora('Setting up...').start();
|
|
1135
1218
|
|
|
@@ -1280,6 +1363,98 @@ shareCmd
|
|
|
1280
1363
|
}
|
|
1281
1364
|
});
|
|
1282
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
|
+
|
|
1283
1458
|
program.parse();
|
|
1284
1459
|
|
|
1285
1460
|
|
package/src/crypto/encryption.js
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import crypto from 'crypto';
|
|
6
|
+
import { Transform } from 'stream';
|
|
6
7
|
|
|
7
8
|
const ALGORITHM = 'aes-256-gcm';
|
|
8
9
|
const KEY_LENGTH = 32; // 256 bits
|
|
9
10
|
const IV_LENGTH = 12; // 96 bits for GCM
|
|
10
11
|
const TAG_LENGTH = 16; // 128 bits auth tag
|
|
11
12
|
const SALT_LENGTH = 32;
|
|
12
|
-
const PBKDF2_ITERATIONS =
|
|
13
|
+
const PBKDF2_ITERATIONS = 600000; // OWASP 2025 recommendation for SHA-512
|
|
13
14
|
|
|
14
15
|
export class Encryptor {
|
|
15
16
|
constructor(password) {
|
|
@@ -18,11 +19,29 @@ export class Encryptor {
|
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
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.
|
|
21
23
|
*/
|
|
22
24
|
getPasswordHash() {
|
|
23
|
-
|
|
24
|
-
|
|
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')
|
|
25
43
|
.digest('hex');
|
|
44
|
+
return legacyHash === storedHash;
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
/**
|
|
@@ -66,6 +85,45 @@ export class Encryptor {
|
|
|
66
85
|
return Buffer.concat([salt, iv, encrypted, authTag]);
|
|
67
86
|
}
|
|
68
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Get an encryption transform stream
|
|
90
|
+
* Needs to append the salt/iv to the stream begin, and authTag to the stream end
|
|
91
|
+
*/
|
|
92
|
+
getEncryptStream() {
|
|
93
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
94
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
95
|
+
const key = this.deriveKey(salt);
|
|
96
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
97
|
+
|
|
98
|
+
let headerWritten = false;
|
|
99
|
+
|
|
100
|
+
return new Transform({
|
|
101
|
+
transform(chunk, encoding, callback) {
|
|
102
|
+
if (!headerWritten) {
|
|
103
|
+
this.push(Buffer.concat([salt, iv]));
|
|
104
|
+
headerWritten = true;
|
|
105
|
+
}
|
|
106
|
+
const encrypted = cipher.update(chunk);
|
|
107
|
+
if (encrypted.length > 0) {
|
|
108
|
+
this.push(encrypted);
|
|
109
|
+
}
|
|
110
|
+
callback();
|
|
111
|
+
},
|
|
112
|
+
flush(callback) {
|
|
113
|
+
if (!headerWritten) {
|
|
114
|
+
this.push(Buffer.concat([salt, iv]));
|
|
115
|
+
headerWritten = true;
|
|
116
|
+
}
|
|
117
|
+
const final = cipher.final();
|
|
118
|
+
if (final.length > 0) {
|
|
119
|
+
this.push(final);
|
|
120
|
+
}
|
|
121
|
+
this.push(cipher.getAuthTag());
|
|
122
|
+
callback();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
69
127
|
/**
|
|
70
128
|
* Decrypt data
|
|
71
129
|
* Input: Buffer containing [salt (32) | iv (12) | ciphertext | authTag (16)]
|
|
@@ -90,6 +148,98 @@ export class Encryptor {
|
|
|
90
148
|
decipher.final()
|
|
91
149
|
]);
|
|
92
150
|
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get a decryption transform stream
|
|
154
|
+
* Expects [salt (32) | iv (12) | ciphertext | authTag (16)]
|
|
155
|
+
*/
|
|
156
|
+
getDecryptStream() {
|
|
157
|
+
let salt = null;
|
|
158
|
+
let iv = null;
|
|
159
|
+
let authTag = null;
|
|
160
|
+
let key = null;
|
|
161
|
+
let decipher = null;
|
|
162
|
+
|
|
163
|
+
// Buffer for storing the salt and iv during the first few chunks
|
|
164
|
+
let headerBuffer = Buffer.alloc(0);
|
|
165
|
+
let headerRead = false;
|
|
166
|
+
|
|
167
|
+
// We must buffer the last 16 bytes across chunks because it's the authTag
|
|
168
|
+
let tailBuffer = Buffer.alloc(0);
|
|
169
|
+
|
|
170
|
+
const self = this;
|
|
171
|
+
|
|
172
|
+
return new Transform({
|
|
173
|
+
transform(chunk, encoding, callback) {
|
|
174
|
+
try {
|
|
175
|
+
// 1. Read the header (salt + iv)
|
|
176
|
+
if (!headerRead) {
|
|
177
|
+
headerBuffer = Buffer.concat([headerBuffer, chunk]);
|
|
178
|
+
|
|
179
|
+
if (headerBuffer.length >= SALT_LENGTH + IV_LENGTH) {
|
|
180
|
+
salt = headerBuffer.subarray(0, SALT_LENGTH);
|
|
181
|
+
iv = headerBuffer.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
|
|
182
|
+
key = self.deriveKey(salt);
|
|
183
|
+
decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
184
|
+
|
|
185
|
+
// The rest of the header buffer is ciphertext
|
|
186
|
+
const remaining = headerBuffer.subarray(SALT_LENGTH + IV_LENGTH);
|
|
187
|
+
headerBuffer = null; // free memory
|
|
188
|
+
headerRead = true;
|
|
189
|
+
|
|
190
|
+
// Push remaining into tailBuffer for processing
|
|
191
|
+
if (remaining.length > 0) {
|
|
192
|
+
tailBuffer = Buffer.concat([tailBuffer, remaining]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else {
|
|
196
|
+
tailBuffer = Buffer.concat([tailBuffer, chunk]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2. Process ciphertext, keeping exactly TAG_LENGTH bytes in tailBuffer
|
|
200
|
+
if (headerRead && tailBuffer.length > TAG_LENGTH) {
|
|
201
|
+
const processLength = tailBuffer.length - TAG_LENGTH;
|
|
202
|
+
const toProcess = tailBuffer.subarray(0, processLength);
|
|
203
|
+
|
|
204
|
+
const decrypted = decipher.update(toProcess);
|
|
205
|
+
if (decrypted.length > 0) {
|
|
206
|
+
this.push(decrypted);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Keep only the end
|
|
210
|
+
tailBuffer = tailBuffer.subarray(processLength);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
callback();
|
|
214
|
+
} catch (err) {
|
|
215
|
+
callback(err);
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
flush(callback) {
|
|
219
|
+
try {
|
|
220
|
+
if (!headerRead) {
|
|
221
|
+
return callback(new Error('Invalid encrypted data stream: too short'));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (tailBuffer.length !== TAG_LENGTH) {
|
|
225
|
+
return callback(new Error(`Invalid encrypted data stream: missing auth tag. Got ${tailBuffer.length} bytes, expected ${TAG_LENGTH}`));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
authTag = tailBuffer;
|
|
229
|
+
decipher.setAuthTag(authTag);
|
|
230
|
+
|
|
231
|
+
const final = decipher.final();
|
|
232
|
+
if (final.length > 0) {
|
|
233
|
+
this.push(final);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
callback();
|
|
237
|
+
} catch (err) {
|
|
238
|
+
callback(new Error(`Decryption failed: wrong password or corrupt data (${err.message})`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
93
243
|
}
|
|
94
244
|
|
|
95
245
|
/**
|
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
|
/**
|