@nightowne/tas-cli 1.0.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.
@@ -0,0 +1,516 @@
1
+ /**
2
+ * FUSE Filesystem Mount
3
+ * Mount Telegram storage as a local folder
4
+ *
5
+ * This is the killer feature - use Telegram like a regular folder!
6
+ */
7
+
8
+ import Fuse from 'fuse-native';
9
+ import path from 'path';
10
+ import fs from 'fs';
11
+ import { TelegramClient } from '../telegram/client.js';
12
+ import { Encryptor } from '../crypto/encryption.js';
13
+ import { Compressor } from '../utils/compression.js';
14
+ import { FileIndex } from '../db/index.js';
15
+ import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
16
+
17
+ // File cache for performance (avoid re-downloading)
18
+ const fileCache = new Map();
19
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
20
+
21
+ export class TelegramFS {
22
+ constructor(options) {
23
+ this.dataDir = options.dataDir;
24
+ this.password = options.password;
25
+ this.config = options.config;
26
+ this.mountPoint = options.mountPoint;
27
+
28
+ this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
29
+ this.db.init();
30
+
31
+ this.encryptor = new Encryptor(this.password);
32
+ this.compressor = new Compressor();
33
+ this.client = null;
34
+ this.fuse = null;
35
+
36
+ // Pending writes buffer
37
+ this.writeBuffers = new Map();
38
+ }
39
+
40
+ async initialize() {
41
+ // Connect to Telegram
42
+ this.client = new TelegramClient(this.dataDir);
43
+ await this.client.initialize(this.config.botToken);
44
+ this.client.setChatId(this.config.chatId);
45
+ }
46
+
47
+ /**
48
+ * Get file attributes
49
+ */
50
+ getattr(filepath, cb) {
51
+ const filename = path.basename(filepath);
52
+
53
+ // Root directory
54
+ if (filepath === '/') {
55
+ return cb(0, {
56
+ mtime: new Date(),
57
+ atime: new Date(),
58
+ ctime: new Date(),
59
+ size: 4096,
60
+ mode: 0o40755, // directory
61
+ uid: process.getuid?.() || 0,
62
+ gid: process.getgid?.() || 0
63
+ });
64
+ }
65
+
66
+ // Check write buffers first (new/pending files)
67
+ const wb = this.writeBuffers.get(filename);
68
+ if (wb) {
69
+ return cb(0, {
70
+ mtime: new Date(),
71
+ atime: new Date(),
72
+ ctime: new Date(),
73
+ size: wb.data.length,
74
+ mode: 0o100644, // regular file
75
+ uid: process.getuid?.() || 0,
76
+ gid: process.getgid?.() || 0
77
+ });
78
+ }
79
+
80
+ // Look up file in index
81
+ const file = this.db.findByName(filename);
82
+
83
+ if (!file) {
84
+ return cb(Fuse.ENOENT);
85
+ }
86
+
87
+ return cb(0, {
88
+ mtime: new Date(file.created_at),
89
+ atime: new Date(file.created_at),
90
+ ctime: new Date(file.created_at),
91
+ size: file.original_size,
92
+ mode: 0o100644, // regular file
93
+ uid: process.getuid?.() || 0,
94
+ gid: process.getgid?.() || 0
95
+ });
96
+ }
97
+
98
+ /**
99
+ * List directory contents
100
+ */
101
+ readdir(filepath, cb) {
102
+ if (filepath !== '/') {
103
+ return cb(Fuse.ENOENT);
104
+ }
105
+
106
+ const files = this.db.listAll();
107
+ const names = files.map(f => f.filename);
108
+
109
+ return cb(0, names);
110
+ }
111
+
112
+ /**
113
+ * Open a file (just validates it exists)
114
+ */
115
+ open(filepath, flags, cb) {
116
+ const filename = path.basename(filepath);
117
+
118
+ // Check if it's a new file being written
119
+ if (this.writeBuffers.has(filename)) {
120
+ return cb(0, 42); // Return a dummy fd
121
+ }
122
+
123
+ const file = this.db.findByName(filename);
124
+
125
+ if (!file) {
126
+ return cb(Fuse.ENOENT);
127
+ }
128
+
129
+ return cb(0, file.id); // Use file ID as file descriptor
130
+ }
131
+
132
+ /**
133
+ * Read file contents
134
+ */
135
+ async read(filepath, fd, buffer, length, position, cb) {
136
+ const filename = path.basename(filepath);
137
+
138
+ try {
139
+ // Check cache first
140
+ let data = this.getCached(filename);
141
+
142
+ if (!data) {
143
+ // Download and decrypt
144
+ data = await this.downloadFile(filename);
145
+ this.setCache(filename, data);
146
+ }
147
+
148
+ // Copy requested portion to buffer
149
+ const slice = data.subarray(position, position + length);
150
+ slice.copy(buffer);
151
+
152
+ return cb(slice.length);
153
+ } catch (err) {
154
+ console.error('Read error:', err.message);
155
+ return cb(Fuse.EIO);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Write to a file (buffers until release)
161
+ */
162
+ write(filepath, fd, buffer, length, position, cb) {
163
+ const filename = path.basename(filepath);
164
+
165
+ // Get or create write buffer
166
+ if (!this.writeBuffers.has(filename)) {
167
+ this.writeBuffers.set(filename, {
168
+ data: Buffer.alloc(0),
169
+ modified: true
170
+ });
171
+ }
172
+
173
+ const wb = this.writeBuffers.get(filename);
174
+
175
+ // Expand buffer if needed
176
+ const newSize = Math.max(wb.data.length, position + length);
177
+ if (newSize > wb.data.length) {
178
+ const newBuf = Buffer.alloc(newSize);
179
+ wb.data.copy(newBuf);
180
+ wb.data = newBuf;
181
+ }
182
+
183
+ // Copy incoming data
184
+ buffer.copy(wb.data, position, 0, length);
185
+ wb.modified = true;
186
+
187
+ return cb(length);
188
+ }
189
+
190
+ /**
191
+ * Create a new file
192
+ */
193
+ create(filepath, mode, cb) {
194
+ const filename = path.basename(filepath);
195
+
196
+ console.log(`[FUSE] Creating file: ${filename}`);
197
+
198
+ // Initialize empty write buffer
199
+ this.writeBuffers.set(filename, {
200
+ data: Buffer.alloc(0),
201
+ modified: true,
202
+ isNew: true
203
+ });
204
+
205
+ return cb(0, 42); // Return a valid fd
206
+ }
207
+
208
+ /**
209
+ * Truncate open file
210
+ */
211
+ ftruncate(filepath, fd, size, cb) {
212
+ return this.truncate(filepath, size, cb);
213
+ }
214
+
215
+ /**
216
+ * Flush/sync file to Telegram
217
+ */
218
+ async release(filepath, fd, cb) {
219
+ const filename = path.basename(filepath);
220
+
221
+ const wb = this.writeBuffers.get(filename);
222
+ if (!wb || !wb.modified) {
223
+ return cb(0);
224
+ }
225
+
226
+ try {
227
+ // Upload to Telegram
228
+ await this.uploadFile(filename, wb.data);
229
+
230
+ // Clear write buffer
231
+ this.writeBuffers.delete(filename);
232
+
233
+ // Invalidate cache
234
+ this.invalidateCache(filename);
235
+
236
+ return cb(0);
237
+ } catch (err) {
238
+ console.error('Release error:', err.message);
239
+ return cb(Fuse.EIO);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Delete a file
245
+ */
246
+ async unlink(filepath, cb) {
247
+ const filename = path.basename(filepath);
248
+ const file = this.db.findByName(filename);
249
+
250
+ if (!file) {
251
+ return cb(Fuse.ENOENT);
252
+ }
253
+
254
+ try {
255
+ // Delete from Telegram (optional - could just remove from index)
256
+ const chunks = this.db.getChunks(file.id);
257
+ for (const chunk of chunks) {
258
+ await this.client.deleteMessage(chunk.message_id);
259
+ }
260
+
261
+ // Remove from index
262
+ this.db.delete(file.id);
263
+
264
+ // Invalidate cache
265
+ this.invalidateCache(filename);
266
+
267
+ return cb(0);
268
+ } catch (err) {
269
+ console.error('Unlink error:', err.message);
270
+ return cb(Fuse.EIO);
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Rename/move a file (just update index, data stays in Telegram)
276
+ */
277
+ rename(src, dest, cb) {
278
+ const oldName = path.basename(src);
279
+ const newName = path.basename(dest);
280
+
281
+ const file = this.db.findByName(oldName);
282
+ if (!file) {
283
+ return cb(Fuse.ENOENT);
284
+ }
285
+
286
+ // Update filename in database
287
+ this.db.db.prepare('UPDATE files SET filename = ? WHERE id = ?')
288
+ .run(newName, file.id);
289
+
290
+ // Update cache key
291
+ const cached = fileCache.get(oldName);
292
+ if (cached) {
293
+ fileCache.delete(oldName);
294
+ fileCache.set(newName, cached);
295
+ }
296
+
297
+ return cb(0);
298
+ }
299
+
300
+ /**
301
+ * Truncate a file
302
+ */
303
+ truncate(filepath, size, cb) {
304
+ const filename = path.basename(filepath);
305
+
306
+ // Get or load into write buffer
307
+ if (!this.writeBuffers.has(filename)) {
308
+ const cached = this.getCached(filename);
309
+ if (cached) {
310
+ this.writeBuffers.set(filename, {
311
+ data: Buffer.from(cached),
312
+ modified: true
313
+ });
314
+ } else {
315
+ this.writeBuffers.set(filename, {
316
+ data: Buffer.alloc(0),
317
+ modified: true
318
+ });
319
+ }
320
+ }
321
+
322
+ const wb = this.writeBuffers.get(filename);
323
+
324
+ if (size < wb.data.length) {
325
+ wb.data = wb.data.subarray(0, size);
326
+ } else if (size > wb.data.length) {
327
+ const newBuf = Buffer.alloc(size);
328
+ wb.data.copy(newBuf);
329
+ wb.data = newBuf;
330
+ }
331
+
332
+ wb.modified = true;
333
+ return cb(0);
334
+ }
335
+
336
+ // ============== Helper Methods ==============
337
+
338
+ async downloadFile(filename) {
339
+ const file = this.db.findByName(filename);
340
+ if (!file) throw new Error('File not found');
341
+
342
+ const chunks = this.db.getChunks(file.id);
343
+ if (chunks.length === 0) throw new Error('No chunks found');
344
+
345
+ // Download all chunks
346
+ const downloadedChunks = [];
347
+
348
+ for (const chunk of chunks) {
349
+ const data = await this.client.downloadFile(chunk.file_telegram_id);
350
+ const header = parseHeader(data);
351
+ const payload = data.subarray(HEADER_SIZE);
352
+
353
+ downloadedChunks.push({
354
+ index: header.chunkIndex,
355
+ data: payload,
356
+ compressed: header.compressed
357
+ });
358
+ }
359
+
360
+ // Reassemble
361
+ downloadedChunks.sort((a, b) => a.index - b.index);
362
+ const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
363
+
364
+ // Decrypt
365
+ const compressedData = this.encryptor.decrypt(encryptedData);
366
+
367
+ // Decompress
368
+ const wasCompressed = downloadedChunks[0].compressed;
369
+ return await this.compressor.decompress(compressedData, wasCompressed);
370
+ }
371
+
372
+ async uploadFile(filename, data) {
373
+ const { hashData } = await import('../crypto/encryption.js');
374
+ const hash = hashData(data);
375
+
376
+ // Check if already exists by name
377
+ const existingByName = this.db.findByName(filename);
378
+ if (existingByName) {
379
+ // Delete old version
380
+ const chunks = this.db.getChunks(existingByName.id);
381
+ for (const chunk of chunks) {
382
+ try { await this.client.deleteMessage(chunk.message_id); } catch (e) { }
383
+ }
384
+ this.db.delete(existingByName.id);
385
+ }
386
+
387
+ // Check if already exists by hash (same content, different name)
388
+ const existingByHash = this.db.findByHash(hash);
389
+ if (existingByHash) {
390
+ // Same content already exists, just skip
391
+ console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
392
+ return;
393
+ }
394
+
395
+ // Compress
396
+ const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
397
+
398
+ // Encrypt
399
+ const encryptedData = this.encryptor.encrypt(compressedData);
400
+
401
+ // Create temp file with header
402
+ const tempDir = path.join(this.dataDir, 'tmp');
403
+ if (!fs.existsSync(tempDir)) {
404
+ fs.mkdirSync(tempDir, { recursive: true });
405
+ }
406
+
407
+ const flags = compressed ? 1 : 0;
408
+ const header = createHeader(filename, data.length, 0, 1, flags);
409
+ const fileData = Buffer.concat([header, encryptedData]);
410
+
411
+ const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
412
+ fs.writeFileSync(tempPath, fileData);
413
+
414
+ // Upload to Telegram
415
+ const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
416
+
417
+ // Add to index
418
+ const fileId = this.db.addFile({
419
+ filename,
420
+ hash,
421
+ originalSize: data.length,
422
+ storedSize: encryptedData.length,
423
+ chunks: 1,
424
+ compressed
425
+ });
426
+
427
+ this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
428
+ this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
429
+ .run(result.fileId, fileId, 0);
430
+
431
+ // Cleanup
432
+ fs.unlinkSync(tempPath);
433
+ try { fs.rmdirSync(tempDir); } catch (e) { }
434
+ }
435
+
436
+ getCached(filename) {
437
+ const entry = fileCache.get(filename);
438
+ if (!entry) return null;
439
+
440
+ if (Date.now() - entry.timestamp > CACHE_TTL) {
441
+ fileCache.delete(filename);
442
+ return null;
443
+ }
444
+
445
+ return entry.data;
446
+ }
447
+
448
+ setCache(filename, data) {
449
+ fileCache.set(filename, {
450
+ data,
451
+ timestamp: Date.now()
452
+ });
453
+ }
454
+
455
+ invalidateCache(filename) {
456
+ fileCache.delete(filename);
457
+ }
458
+
459
+ /**
460
+ * Mount the filesystem
461
+ */
462
+ mount() {
463
+ // Ensure mount point exists
464
+ if (!fs.existsSync(this.mountPoint)) {
465
+ fs.mkdirSync(this.mountPoint, { recursive: true });
466
+ }
467
+
468
+ const ops = {
469
+ getattr: this.getattr.bind(this),
470
+ readdir: this.readdir.bind(this),
471
+ open: this.open.bind(this),
472
+ read: this.read.bind(this),
473
+ write: this.write.bind(this),
474
+ create: this.create.bind(this),
475
+ release: this.release.bind(this),
476
+ unlink: this.unlink.bind(this),
477
+ rename: this.rename.bind(this),
478
+ truncate: this.truncate.bind(this),
479
+ ftruncate: this.ftruncate.bind(this)
480
+ };
481
+
482
+ this.fuse = new Fuse(this.mountPoint, ops, {
483
+ debug: false,
484
+ force: true,
485
+ mkdir: true
486
+ });
487
+
488
+ return new Promise((resolve, reject) => {
489
+ this.fuse.mount((err) => {
490
+ if (err) {
491
+ reject(err);
492
+ } else {
493
+ resolve();
494
+ }
495
+ });
496
+ });
497
+ }
498
+
499
+ /**
500
+ * Unmount the filesystem
501
+ */
502
+ unmount() {
503
+ return new Promise((resolve, reject) => {
504
+ if (!this.fuse) return resolve();
505
+
506
+ this.fuse.unmount((err) => {
507
+ if (err) {
508
+ reject(err);
509
+ } else {
510
+ this.db.close();
511
+ resolve();
512
+ }
513
+ });
514
+ });
515
+ }
516
+ }