@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.
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/package.json +57 -0
- package/src/cli.js +1010 -0
- package/src/crypto/encryption.js +116 -0
- package/src/db/index.js +356 -0
- package/src/fuse/mount.js +516 -0
- package/src/index.js +219 -0
- package/src/sync/sync.js +297 -0
- package/src/telegram/client.js +131 -0
- package/src/utils/branding.js +94 -0
- package/src/utils/chunker.js +155 -0
- package/src/utils/compression.js +84 -0
- package/systemd/README.md +32 -0
- package/systemd/tas-sync.service +21 -0
|
@@ -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
|
+
}
|