@nightowne/tas-cli 2.3.0 → 2.4.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nightowne/tas-cli",
3
- "version": "2.3.0",
4
- "description": "Telegram as Storage - Automated encrypted cloud backup. Free, encrypted, scriptable. Mount as folder or use with cron/Docker.",
3
+ "version": "2.4.1",
4
+ "description": "Turn Telegram into unlimited free cloud storage ($0/month forever) • AES-256-GCM encryption • FUSE mount • CLI-first • Zero-knowledge • No signup required",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "chalk": "^5.3.0",
48
48
  "commander": "^12.1.0",
49
49
  "inquirer": "^12.2.0",
50
- "node-telegram-bot-api": "^0.66.0",
50
+ "node-telegram-bot-api": "^1.1.2",
51
51
  "ora": "^8.1.1"
52
52
  },
53
53
  "optionalDependencies": {
@@ -56,4 +56,4 @@
56
56
  "engines": {
57
57
  "node": ">=18.0.0"
58
58
  }
59
- }
59
+ }
package/src/cli.js CHANGED
@@ -133,6 +133,8 @@ program
133
133
  createdAt: new Date().toISOString(),
134
134
  configVersion: 2
135
135
  }, null, 2));
136
+ // Restrict config file permissions — contains encrypted token and password hash
137
+ try { fs.chmodSync(configPath, 0o600); } catch { /* ignore on Windows */ }
136
138
 
137
139
  // Initialize database
138
140
  spinner.start('Initializing local index...');
@@ -742,7 +744,7 @@ syncCmd
742
744
  if (options.limit) {
743
745
  const match = options.limit.match(/^(\d+)([kmg]?)$/i);
744
746
  if (!match) {
745
- console.error(chalk.red('Invalid limit format. Use e.g. 500{}, 1m'));
747
+ console.error(chalk.red('Invalid limit format. Use e.g. 500k, 1m'));
746
748
  process.exit(1);
747
749
  }
748
750
  const val = parseInt(match[1]);
@@ -28,12 +28,18 @@ export class Encryptor {
28
28
 
29
29
  /**
30
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.
31
32
  */
32
33
  static verifyPasswordHash(password, storedHash) {
33
34
  const encryptor = new Encryptor(password);
34
35
 
36
+ const computedHash = encryptor.getPasswordHash();
37
+ const computedBuf = Buffer.from(computedHash, 'utf-8');
38
+ const storedBuf = Buffer.from(storedHash, 'utf-8');
39
+
35
40
  // Try new PBKDF2-based verification first
36
- if (encryptor.getPasswordHash() === storedHash) {
41
+ if (computedBuf.length === storedBuf.length &&
42
+ crypto.timingSafeEqual(computedBuf, storedBuf)) {
37
43
  return true;
38
44
  }
39
45
 
@@ -41,7 +47,14 @@ export class Encryptor {
41
47
  const legacyHash = crypto.createHash('sha256')
42
48
  .update(password + 'was-verify')
43
49
  .digest('hex');
44
- return legacyHash === storedHash;
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;
45
58
  }
46
59
 
47
60
  /**
package/src/fuse/mount.js CHANGED
@@ -7,8 +7,7 @@
7
7
 
8
8
  import path from 'path';
9
9
  import fs from 'fs';
10
- import crypto from 'crypto';
11
- import { processFile } from '../index.js';
10
+ import { pipeline } from 'stream/promises';
12
11
 
13
12
  let Fuse;
14
13
  try {
@@ -20,8 +19,11 @@ import { TelegramClient } from '../telegram/client.js';
20
19
  import { Encryptor } from '../crypto/encryption.js';
21
20
  import { Compressor } from '../utils/compression.js';
22
21
  import { FileIndex } from '../db/index.js';
23
- import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
22
+ import { createHeader } from '../utils/chunker.js';
23
+ import { createDownloadPipeline } from '../utils/download-stream.js';
24
24
 
25
+ // File cache for performance (avoid re-downloading)
26
+ const fileCache = new Map();
25
27
  const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
26
28
  const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
27
29
 
@@ -52,9 +54,6 @@ export class TelegramFS {
52
54
 
53
55
  // Pending writes buffer
54
56
  this.writeBuffers = new Map();
55
-
56
- // Instance-level file cache to prevent cross-talk
57
- this.fileCache = new Map();
58
57
  }
59
58
 
60
59
  async initialize() {
@@ -90,7 +89,7 @@ export class TelegramFS {
90
89
  mtime: new Date(),
91
90
  atime: new Date(),
92
91
  ctime: new Date(),
93
- size: wb.size,
92
+ size: wb.data.length,
94
93
  mode: 0o100644, // regular file
95
94
  uid: process.getuid?.() || 0,
96
95
  gid: process.getgid?.() || 0
@@ -124,14 +123,9 @@ export class TelegramFS {
124
123
  }
125
124
 
126
125
  const files = this.db.listAll();
127
- const nameSet = new Set(files.map(f => f.filename));
126
+ const names = files.map(f => f.filename);
128
127
 
129
- // Include files currently being written (not yet released/uploaded)
130
- for (const name of this.writeBuffers.keys()) {
131
- nameSet.add(name);
132
- }
133
-
134
- return cb(0, [...nameSet]);
128
+ return cb(0, names);
135
129
  }
136
130
 
137
131
  /**
@@ -164,8 +158,9 @@ export class TelegramFS {
164
158
  // Check write buffers first
165
159
  const wb = this.writeBuffers.get(filename);
166
160
  if (wb) {
167
- const bytesRead = fs.readSync(wb.fd, buffer, 0, length, position);
168
- return cb(bytesRead);
161
+ const slice = wb.data.subarray(position, position + length);
162
+ slice.copy(buffer);
163
+ return cb(slice.length);
169
164
  }
170
165
 
171
166
  // Check cache first
@@ -190,55 +185,34 @@ export class TelegramFS {
190
185
  }
191
186
 
192
187
  /**
193
- * Helper to get or create a disk-buffered write structure
188
+ * Write to a file (buffers until release)
194
189
  */
195
- getOrCreateWriteBuffer(filename) {
196
- if (this.writeBuffers.has(filename)) {
197
- return this.writeBuffers.get(filename);
198
- }
190
+ write(filepath, fd, buffer, length, position, cb) {
191
+ const filename = path.basename(filepath);
199
192
 
200
- const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
201
- if (!fs.existsSync(fuseTmpDir)) {
202
- fs.mkdirSync(fuseTmpDir, { recursive: true });
193
+ // Get or create write buffer
194
+ if (!this.writeBuffers.has(filename)) {
195
+ this.writeBuffers.set(filename, {
196
+ data: Buffer.alloc(0),
197
+ modified: true
198
+ });
203
199
  }
204
200
 
205
- const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
206
- const fdDisk = fs.openSync(tmpPath, 'w+');
201
+ const wb = this.writeBuffers.get(filename);
207
202
 
208
- // Check if there is an existing file cached
209
- const cachedPath = this.getCached(filename);
210
- let size = 0;
211
- if (cachedPath && fs.existsSync(cachedPath)) {
212
- const content = fs.readFileSync(cachedPath);
213
- fs.writeSync(fdDisk, content, 0, content.length, 0);
214
- size = content.length;
203
+ // Expand buffer if needed
204
+ const newSize = Math.max(wb.data.length, position + length);
205
+ if (newSize > wb.data.length) {
206
+ const newBuf = Buffer.alloc(newSize);
207
+ wb.data.copy(newBuf);
208
+ wb.data = newBuf;
215
209
  }
216
210
 
217
- const wb = {
218
- tmpPath,
219
- fd: fdDisk,
220
- size,
221
- modified: true
222
- };
223
- this.writeBuffers.set(filename, wb);
224
- return wb;
225
- }
211
+ // Copy incoming data
212
+ buffer.copy(wb.data, position, 0, length);
213
+ wb.modified = true;
226
214
 
227
- /**
228
- * Write to a file (buffers until release)
229
- */
230
- write(filepath, fd, buffer, length, position, cb) {
231
- const filename = path.basename(filepath);
232
- try {
233
- const wb = this.getOrCreateWriteBuffer(filename);
234
- fs.writeSync(wb.fd, buffer, 0, length, position);
235
- wb.size = Math.max(wb.size, position + length);
236
- wb.modified = true;
237
- return cb(length);
238
- } catch (err) {
239
- console.error('[FUSE] Write error:', err.message);
240
- return cb(Fuse.EIO);
241
- }
215
+ return cb(length);
242
216
  }
243
217
 
244
218
  /**
@@ -249,19 +223,9 @@ export class TelegramFS {
249
223
 
250
224
  console.log(`[FUSE] Creating file: ${filename}`);
251
225
 
252
- // Initialize empty disk-buffered write
253
- const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
254
- if (!fs.existsSync(fuseTmpDir)) {
255
- fs.mkdirSync(fuseTmpDir, { recursive: true });
256
- }
257
-
258
- const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
259
- const fdDisk = fs.openSync(tmpPath, 'w+');
260
-
226
+ // Initialize empty write buffer
261
227
  this.writeBuffers.set(filename, {
262
- tmpPath,
263
- fd: fdDisk,
264
- size: 0,
228
+ data: Buffer.alloc(0),
265
229
  modified: true,
266
230
  isNew: true
267
231
  });
@@ -283,26 +247,15 @@ export class TelegramFS {
283
247
  const filename = path.basename(filepath);
284
248
 
285
249
  const wb = this.writeBuffers.get(filename);
286
- if (!wb) {
287
- return cb(0);
288
- }
289
-
290
- if (!wb.modified) {
291
- try { fs.closeSync(wb.fd); } catch (e) {}
292
- try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
293
- this.writeBuffers.delete(filename);
250
+ if (!wb || !wb.modified) {
294
251
  return cb(0);
295
252
  }
296
253
 
297
254
  try {
298
- // Close fd so we can read and upload cleanly
299
- try { fs.closeSync(wb.fd); } catch (e) {}
255
+ // Upload to Telegram
256
+ await this.uploadFile(filename, wb.data);
300
257
 
301
- // Upload to Telegram from the temp path
302
- await this.uploadFile(filename, wb.tmpPath);
303
-
304
- // Clean up temp file
305
- try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
258
+ // Clear write buffer
306
259
  this.writeBuffers.delete(filename);
307
260
 
308
261
  // Invalidate cache
@@ -310,9 +263,7 @@ export class TelegramFS {
310
263
 
311
264
  return cb(0);
312
265
  } catch (err) {
313
- console.error('[FUSE] Release error:', err.message);
314
- try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
315
- this.writeBuffers.delete(filename);
266
+ console.error('Release error:', err.message);
316
267
  return cb(Fuse.EIO);
317
268
  }
318
269
  }
@@ -329,7 +280,7 @@ export class TelegramFS {
329
280
  }
330
281
 
331
282
  try {
332
- // Delete from Telegram
283
+ // Delete from Telegram (optional - could just remove from index)
333
284
  const chunks = this.db.getChunks(file.id);
334
285
  for (const chunk of chunks) {
335
286
  await this.client.deleteMessage(chunk.message_id);
@@ -343,7 +294,7 @@ export class TelegramFS {
343
294
 
344
295
  return cb(0);
345
296
  } catch (err) {
346
- console.error('[FUSE] Unlink error:', err.message);
297
+ console.error('Unlink error:', err.message);
347
298
  return cb(Fuse.EIO);
348
299
  }
349
300
  }
@@ -365,10 +316,10 @@ export class TelegramFS {
365
316
  .run(newName, file.id);
366
317
 
367
318
  // Update cache key
368
- const cached = this.fileCache.get(oldName);
319
+ const cached = fileCache.get(oldName);
369
320
  if (cached) {
370
- this.fileCache.delete(oldName);
371
- this.fileCache.set(newName, cached);
321
+ fileCache.delete(oldName);
322
+ fileCache.set(newName, cached);
372
323
  }
373
324
 
374
325
  return cb(0);
@@ -379,16 +330,35 @@ export class TelegramFS {
379
330
  */
380
331
  truncate(filepath, size, cb) {
381
332
  const filename = path.basename(filepath);
382
- try {
383
- const wb = this.getOrCreateWriteBuffer(filename);
384
- fs.ftruncateSync(wb.fd, size);
385
- wb.size = size;
386
- wb.modified = true;
387
- return cb(0);
388
- } catch (err) {
389
- console.error('[FUSE] Truncate error:', err.message);
390
- return cb(Fuse.EIO);
333
+
334
+ // Get or load into write buffer
335
+ if (!this.writeBuffers.has(filename)) {
336
+ const cachedPath = this.getCached(filename);
337
+ if (cachedPath) {
338
+ this.writeBuffers.set(filename, {
339
+ data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
340
+ modified: true
341
+ });
342
+ } else {
343
+ this.writeBuffers.set(filename, {
344
+ data: Buffer.alloc(0),
345
+ modified: true
346
+ });
347
+ }
391
348
  }
349
+
350
+ const wb = this.writeBuffers.get(filename);
351
+
352
+ if (size < wb.data.length) {
353
+ wb.data = wb.data.subarray(0, size);
354
+ } else if (size > wb.data.length) {
355
+ const newBuf = Buffer.alloc(size);
356
+ wb.data.copy(newBuf);
357
+ wb.data = newBuf;
358
+ }
359
+
360
+ wb.modified = true;
361
+ return cb(0);
392
362
  }
393
363
 
394
364
  // ============== Helper Methods ==============
@@ -413,56 +383,18 @@ export class TelegramFS {
413
383
  }
414
384
 
415
385
  const chunks = this.db.getChunks(file.id);
416
- if (chunks.length === 0) throw new Error('No chunks found');
417
-
418
- // Pre-sort chunks
419
- chunks.sort((a, b) => a.chunk_index - b.chunk_index);
420
-
421
- const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
422
- const header = parseHeader(firstChunkData);
423
- let wasCompressed = header.compressed;
424
-
425
- const decryptStream = this.encryptor.getDecryptStream();
426
- const decompressStream = this.compressor.getDecompressStream(wasCompressed);
427
-
428
- const { Readable } = await import('stream');
429
- const { pipeline } = await import('stream/promises');
430
-
431
- const self = this;
432
- let currentChunkIndex = 0;
433
- let preloadedFirstChunk = firstChunkData;
434
-
435
- const downloadStream = new Readable({
436
- async read() {
437
- try {
438
- if (currentChunkIndex >= chunks.length) {
439
- this.push(null);
440
- return;
441
- }
442
-
443
- const chunk = chunks[currentChunkIndex];
444
- let data;
445
- if (currentChunkIndex === 0 && preloadedFirstChunk) {
446
- data = preloadedFirstChunk;
447
- preloadedFirstChunk = null;
448
- } else {
449
- data = await self.client.downloadFile(chunk.file_telegram_id);
450
- }
451
-
452
- const payload = data.subarray(HEADER_SIZE);
453
- this.push(payload);
454
- currentChunkIndex++;
455
- } catch (err) {
456
- this.destroy(err);
457
- }
458
- }
386
+
387
+ const { readable } = await createDownloadPipeline({
388
+ client: this.client,
389
+ chunks,
390
+ encryptor: this.encryptor,
391
+ compressor: this.compressor
459
392
  });
460
393
 
461
394
  const tmpOutputPath = outputPath + '.tmp';
462
395
  const writeStream = fs.createWriteStream(tmpOutputPath);
463
396
 
464
- // Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
465
- await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
397
+ await pipeline(readable, writeStream);
466
398
 
467
399
  // Rename to final atomic path
468
400
  fs.renameSync(tmpOutputPath, outputPath);
@@ -470,7 +402,10 @@ export class TelegramFS {
470
402
  return outputPath;
471
403
  }
472
404
 
473
- async uploadFile(filename, tempPath) {
405
+ async uploadFile(filename, data) {
406
+ const { hashData } = await import('../crypto/encryption.js');
407
+ const hash = hashData(data);
408
+
474
409
  // Check if already exists by name
475
410
  const existingByName = this.db.findByName(filename);
476
411
  if (existingByName) {
@@ -482,17 +417,57 @@ export class TelegramFS {
482
417
  this.db.delete(existingByName.id);
483
418
  }
484
419
 
485
- // Run the standard chunked upload pipeline (this handles chunking, encryption, compression, and DB entry)
486
- await processFile(tempPath, {
487
- password: this.password,
488
- dataDir: this.dataDir,
489
- customName: filename,
490
- config: this.config
420
+ // Check if already exists by hash (same content, different name)
421
+ const existingByHash = this.db.findByHash(hash);
422
+ if (existingByHash) {
423
+ // Same content already exists, just skip
424
+ console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
425
+ return;
426
+ }
427
+
428
+ // Compress
429
+ const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
430
+
431
+ // Encrypt
432
+ const encryptedData = this.encryptor.encrypt(compressedData);
433
+
434
+ // Create temp file with header
435
+ const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
436
+ if (!fs.existsSync(tempDir)) {
437
+ fs.mkdirSync(tempDir, { recursive: true });
438
+ }
439
+
440
+ const flags = compressed ? 1 : 0;
441
+ const header = createHeader(filename, data.length, 0, 1, flags);
442
+ const fileData = Buffer.concat([header, encryptedData]);
443
+
444
+ const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
445
+ fs.writeFileSync(tempPath, fileData);
446
+
447
+ // Upload to Telegram
448
+ const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
449
+
450
+ // Add to index
451
+ const fileId = this.db.addFile({
452
+ filename,
453
+ hash,
454
+ originalSize: data.length,
455
+ storedSize: encryptedData.length,
456
+ chunks: 1,
457
+ compressed
491
458
  });
459
+
460
+ this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
461
+ this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
462
+ .run(result.fileId, fileId, 0);
463
+
464
+ // Cleanup
465
+ fs.unlinkSync(tempPath);
466
+ try { fs.rmdirSync(tempDir); } catch (e) { }
492
467
  }
493
468
 
494
469
  getCached(filename) {
495
- const entry = this.fileCache.get(filename);
470
+ const entry = fileCache.get(filename);
496
471
  if (!entry) return null;
497
472
 
498
473
  if (Date.now() - entry.timestamp > CACHE_TTL) {
@@ -500,7 +475,7 @@ export class TelegramFS {
500
475
  try {
501
476
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
502
477
  } catch (e) { }
503
- this.fileCache.delete(filename);
478
+ fileCache.delete(filename);
504
479
  return null;
505
480
  }
506
481
 
@@ -511,35 +486,35 @@ export class TelegramFS {
511
486
 
512
487
  setCache(filename, cachePath) {
513
488
  // Evict oldest entry if cache is full
514
- if (this.fileCache.size >= CACHE_MAX_ENTRIES) {
489
+ if (fileCache.size >= CACHE_MAX_ENTRIES) {
515
490
  let oldestKey = null;
516
491
  let oldestTime = Infinity;
517
- for (const [key, entry] of this.fileCache) {
492
+ for (const [key, entry] of fileCache) {
518
493
  if (entry.timestamp < oldestTime) {
519
494
  oldestTime = entry.timestamp;
520
495
  oldestKey = key;
521
496
  }
522
497
  }
523
498
  if (oldestKey) {
524
- const evicted = this.fileCache.get(oldestKey);
499
+ const evicted = fileCache.get(oldestKey);
525
500
  try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
526
- this.fileCache.delete(oldestKey);
501
+ fileCache.delete(oldestKey);
527
502
  }
528
503
  }
529
504
 
530
- this.fileCache.set(filename, {
505
+ fileCache.set(filename, {
531
506
  path: cachePath,
532
507
  timestamp: Date.now()
533
508
  });
534
509
  }
535
510
 
536
511
  invalidateCache(filename) {
537
- const entry = this.fileCache.get(filename);
512
+ const entry = fileCache.get(filename);
538
513
  if (entry) {
539
514
  try {
540
515
  if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
541
516
  } catch (e) { }
542
- this.fileCache.delete(filename);
517
+ fileCache.delete(filename);
543
518
  }
544
519
  }
545
520
 
@@ -552,17 +527,6 @@ export class TelegramFS {
552
527
  fs.mkdirSync(this.mountPoint, { recursive: true });
553
528
  }
554
529
 
555
- // Clean up any stray temp files from previous sessions
556
- const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
557
- if (fs.existsSync(fuseTmpDir)) {
558
- try {
559
- const files = fs.readdirSync(fuseTmpDir);
560
- for (const file of files) {
561
- fs.unlinkSync(path.join(fuseTmpDir, file));
562
- }
563
- } catch (e) {}
564
- }
565
-
566
530
  const ops = {
567
531
  getattr: this.getattr.bind(this),
568
532
  readdir: this.readdir.bind(this),