@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/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
 
@@ -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 config = requireConfig(DATA_DIR);
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 config = requireConfig(DATA_DIR);
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
- .action(async () => {
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 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);
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 config = loadConfig(DATA_DIR);
1037
- if (!config) {
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 config = requireConfig(DATA_DIR);
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
 
@@ -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 = 100000;
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
- return crypto.createHash('sha256')
24
- .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')
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
- * 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
  /**