@nightowne/tas-cli 2.1.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 +307 -92
- package/package.json +4 -2
- package/src/cli.js +179 -23
- package/src/crypto/encryption.js +25 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +158 -124
- package/src/index.js +44 -17
- package/src/share/server.js +55 -16
- package/src/sync/sync.js +159 -23
- package/src/telegram/client.js +64 -16
- package/src/utils/branding.js +10 -3
- package/src/utils/chunker.js +4 -2
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/progress.js +3 -3
package/src/fuse/mount.js
CHANGED
|
@@ -5,21 +5,38 @@
|
|
|
5
5
|
* This is the killer feature - use Telegram like a regular folder!
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import Fuse from 'fuse-native';
|
|
9
8
|
import path from 'path';
|
|
10
9
|
import fs from 'fs';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { processFile } from '../index.js';
|
|
12
|
+
|
|
13
|
+
let Fuse;
|
|
14
|
+
try {
|
|
15
|
+
Fuse = (await import('fuse-native')).default;
|
|
16
|
+
} catch {
|
|
17
|
+
// fuse-native is optional — unavailable on ARM64 or systems without libfuse
|
|
18
|
+
}
|
|
11
19
|
import { TelegramClient } from '../telegram/client.js';
|
|
12
20
|
import { Encryptor } from '../crypto/encryption.js';
|
|
13
21
|
import { Compressor } from '../utils/compression.js';
|
|
14
22
|
import { FileIndex } from '../db/index.js';
|
|
15
23
|
import { createHeader, parseHeader, HEADER_SIZE } from '../utils/chunker.js';
|
|
16
24
|
|
|
17
|
-
// File cache for performance (avoid re-downloading)
|
|
18
|
-
const fileCache = new Map();
|
|
19
25
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
26
|
+
const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
|
|
20
27
|
|
|
21
28
|
export class TelegramFS {
|
|
22
29
|
constructor(options) {
|
|
30
|
+
if (!Fuse) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'fuse-native is not available on this system.\n' +
|
|
33
|
+
' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
|
|
34
|
+
' On macOS: brew install macfuse && npm install fuse-native\n' +
|
|
35
|
+
' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
|
|
36
|
+
' All other TAS commands (push, pull, sync, share) work without FUSE.'
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
23
40
|
this.dataDir = options.dataDir;
|
|
24
41
|
this.password = options.password;
|
|
25
42
|
this.config = options.config;
|
|
@@ -35,6 +52,9 @@ export class TelegramFS {
|
|
|
35
52
|
|
|
36
53
|
// Pending writes buffer
|
|
37
54
|
this.writeBuffers = new Map();
|
|
55
|
+
|
|
56
|
+
// Instance-level file cache to prevent cross-talk
|
|
57
|
+
this.fileCache = new Map();
|
|
38
58
|
}
|
|
39
59
|
|
|
40
60
|
async initialize() {
|
|
@@ -70,7 +90,7 @@ export class TelegramFS {
|
|
|
70
90
|
mtime: new Date(),
|
|
71
91
|
atime: new Date(),
|
|
72
92
|
ctime: new Date(),
|
|
73
|
-
size: wb.
|
|
93
|
+
size: wb.size,
|
|
74
94
|
mode: 0o100644, // regular file
|
|
75
95
|
uid: process.getuid?.() || 0,
|
|
76
96
|
gid: process.getgid?.() || 0
|
|
@@ -104,9 +124,14 @@ export class TelegramFS {
|
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
const files = this.db.listAll();
|
|
107
|
-
const
|
|
127
|
+
const nameSet = new Set(files.map(f => f.filename));
|
|
128
|
+
|
|
129
|
+
// Include files currently being written (not yet released/uploaded)
|
|
130
|
+
for (const name of this.writeBuffers.keys()) {
|
|
131
|
+
nameSet.add(name);
|
|
132
|
+
}
|
|
108
133
|
|
|
109
|
-
return cb(0,
|
|
134
|
+
return cb(0, [...nameSet]);
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
/**
|
|
@@ -139,9 +164,8 @@ export class TelegramFS {
|
|
|
139
164
|
// Check write buffers first
|
|
140
165
|
const wb = this.writeBuffers.get(filename);
|
|
141
166
|
if (wb) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
return cb(slice.length);
|
|
167
|
+
const bytesRead = fs.readSync(wb.fd, buffer, 0, length, position);
|
|
168
|
+
return cb(bytesRead);
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
// Check cache first
|
|
@@ -166,34 +190,55 @@ export class TelegramFS {
|
|
|
166
190
|
}
|
|
167
191
|
|
|
168
192
|
/**
|
|
169
|
-
*
|
|
193
|
+
* Helper to get or create a disk-buffered write structure
|
|
170
194
|
*/
|
|
171
|
-
|
|
172
|
-
|
|
195
|
+
getOrCreateWriteBuffer(filename) {
|
|
196
|
+
if (this.writeBuffers.has(filename)) {
|
|
197
|
+
return this.writeBuffers.get(filename);
|
|
198
|
+
}
|
|
173
199
|
|
|
174
|
-
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
data: Buffer.alloc(0),
|
|
178
|
-
modified: true
|
|
179
|
-
});
|
|
200
|
+
const fuseTmpDir = path.join(this.dataDir, 'fuse-tmp');
|
|
201
|
+
if (!fs.existsSync(fuseTmpDir)) {
|
|
202
|
+
fs.mkdirSync(fuseTmpDir, { recursive: true });
|
|
180
203
|
}
|
|
181
204
|
|
|
182
|
-
const
|
|
205
|
+
const tmpPath = path.join(fuseTmpDir, crypto.randomBytes(16).toString('hex'));
|
|
206
|
+
const fdDisk = fs.openSync(tmpPath, 'w+');
|
|
183
207
|
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
190
215
|
}
|
|
191
216
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
217
|
+
const wb = {
|
|
218
|
+
tmpPath,
|
|
219
|
+
fd: fdDisk,
|
|
220
|
+
size,
|
|
221
|
+
modified: true
|
|
222
|
+
};
|
|
223
|
+
this.writeBuffers.set(filename, wb);
|
|
224
|
+
return wb;
|
|
225
|
+
}
|
|
195
226
|
|
|
196
|
-
|
|
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
|
+
}
|
|
197
242
|
}
|
|
198
243
|
|
|
199
244
|
/**
|
|
@@ -204,9 +249,19 @@ export class TelegramFS {
|
|
|
204
249
|
|
|
205
250
|
console.log(`[FUSE] Creating file: ${filename}`);
|
|
206
251
|
|
|
207
|
-
// Initialize empty write
|
|
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
|
+
|
|
208
261
|
this.writeBuffers.set(filename, {
|
|
209
|
-
|
|
262
|
+
tmpPath,
|
|
263
|
+
fd: fdDisk,
|
|
264
|
+
size: 0,
|
|
210
265
|
modified: true,
|
|
211
266
|
isNew: true
|
|
212
267
|
});
|
|
@@ -228,15 +283,26 @@ export class TelegramFS {
|
|
|
228
283
|
const filename = path.basename(filepath);
|
|
229
284
|
|
|
230
285
|
const wb = this.writeBuffers.get(filename);
|
|
231
|
-
if (!wb
|
|
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);
|
|
232
294
|
return cb(0);
|
|
233
295
|
}
|
|
234
296
|
|
|
235
297
|
try {
|
|
236
|
-
//
|
|
237
|
-
|
|
298
|
+
// Close fd so we can read and upload cleanly
|
|
299
|
+
try { fs.closeSync(wb.fd); } catch (e) {}
|
|
300
|
+
|
|
301
|
+
// Upload to Telegram from the temp path
|
|
302
|
+
await this.uploadFile(filename, wb.tmpPath);
|
|
238
303
|
|
|
239
|
-
//
|
|
304
|
+
// Clean up temp file
|
|
305
|
+
try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
|
|
240
306
|
this.writeBuffers.delete(filename);
|
|
241
307
|
|
|
242
308
|
// Invalidate cache
|
|
@@ -244,7 +310,9 @@ export class TelegramFS {
|
|
|
244
310
|
|
|
245
311
|
return cb(0);
|
|
246
312
|
} catch (err) {
|
|
247
|
-
console.error('Release error:', err.message);
|
|
313
|
+
console.error('[FUSE] Release error:', err.message);
|
|
314
|
+
try { fs.unlinkSync(wb.tmpPath); } catch (e) {}
|
|
315
|
+
this.writeBuffers.delete(filename);
|
|
248
316
|
return cb(Fuse.EIO);
|
|
249
317
|
}
|
|
250
318
|
}
|
|
@@ -261,7 +329,7 @@ export class TelegramFS {
|
|
|
261
329
|
}
|
|
262
330
|
|
|
263
331
|
try {
|
|
264
|
-
// Delete from Telegram
|
|
332
|
+
// Delete from Telegram
|
|
265
333
|
const chunks = this.db.getChunks(file.id);
|
|
266
334
|
for (const chunk of chunks) {
|
|
267
335
|
await this.client.deleteMessage(chunk.message_id);
|
|
@@ -275,7 +343,7 @@ export class TelegramFS {
|
|
|
275
343
|
|
|
276
344
|
return cb(0);
|
|
277
345
|
} catch (err) {
|
|
278
|
-
console.error('Unlink error:', err.message);
|
|
346
|
+
console.error('[FUSE] Unlink error:', err.message);
|
|
279
347
|
return cb(Fuse.EIO);
|
|
280
348
|
}
|
|
281
349
|
}
|
|
@@ -297,10 +365,10 @@ export class TelegramFS {
|
|
|
297
365
|
.run(newName, file.id);
|
|
298
366
|
|
|
299
367
|
// Update cache key
|
|
300
|
-
const cached = fileCache.get(oldName);
|
|
368
|
+
const cached = this.fileCache.get(oldName);
|
|
301
369
|
if (cached) {
|
|
302
|
-
fileCache.delete(oldName);
|
|
303
|
-
fileCache.set(newName, cached);
|
|
370
|
+
this.fileCache.delete(oldName);
|
|
371
|
+
this.fileCache.set(newName, cached);
|
|
304
372
|
}
|
|
305
373
|
|
|
306
374
|
return cb(0);
|
|
@@ -311,35 +379,16 @@ export class TelegramFS {
|
|
|
311
379
|
*/
|
|
312
380
|
truncate(filepath, size, cb) {
|
|
313
381
|
const filename = path.basename(filepath);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
} else {
|
|
324
|
-
this.writeBuffers.set(filename, {
|
|
325
|
-
data: Buffer.alloc(0),
|
|
326
|
-
modified: true
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const wb = this.writeBuffers.get(filename);
|
|
332
|
-
|
|
333
|
-
if (size < wb.data.length) {
|
|
334
|
-
wb.data = wb.data.subarray(0, size);
|
|
335
|
-
} else if (size > wb.data.length) {
|
|
336
|
-
const newBuf = Buffer.alloc(size);
|
|
337
|
-
wb.data.copy(newBuf);
|
|
338
|
-
wb.data = newBuf;
|
|
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);
|
|
339
391
|
}
|
|
340
|
-
|
|
341
|
-
wb.modified = true;
|
|
342
|
-
return cb(0);
|
|
343
392
|
}
|
|
344
393
|
|
|
345
394
|
// ============== Helper Methods ==============
|
|
@@ -421,10 +470,7 @@ export class TelegramFS {
|
|
|
421
470
|
return outputPath;
|
|
422
471
|
}
|
|
423
472
|
|
|
424
|
-
async uploadFile(filename,
|
|
425
|
-
const { hashData } = await import('../crypto/encryption.js');
|
|
426
|
-
const hash = hashData(data);
|
|
427
|
-
|
|
473
|
+
async uploadFile(filename, tempPath) {
|
|
428
474
|
// Check if already exists by name
|
|
429
475
|
const existingByName = this.db.findByName(filename);
|
|
430
476
|
if (existingByName) {
|
|
@@ -436,57 +482,17 @@ export class TelegramFS {
|
|
|
436
482
|
this.db.delete(existingByName.id);
|
|
437
483
|
}
|
|
438
484
|
|
|
439
|
-
//
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Compress
|
|
448
|
-
const { data: compressedData, compressed } = await this.compressor.compress(data, filename);
|
|
449
|
-
|
|
450
|
-
// Encrypt
|
|
451
|
-
const encryptedData = this.encryptor.encrypt(compressedData);
|
|
452
|
-
|
|
453
|
-
// Create temp file with header
|
|
454
|
-
const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
|
|
455
|
-
if (!fs.existsSync(tempDir)) {
|
|
456
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const flags = compressed ? 1 : 0;
|
|
460
|
-
const header = createHeader(filename, data.length, 0, 1, flags);
|
|
461
|
-
const fileData = Buffer.concat([header, encryptedData]);
|
|
462
|
-
|
|
463
|
-
const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
|
|
464
|
-
fs.writeFileSync(tempPath, fileData);
|
|
465
|
-
|
|
466
|
-
// Upload to Telegram
|
|
467
|
-
const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
|
|
468
|
-
|
|
469
|
-
// Add to index
|
|
470
|
-
const fileId = this.db.addFile({
|
|
471
|
-
filename,
|
|
472
|
-
hash,
|
|
473
|
-
originalSize: data.length,
|
|
474
|
-
storedSize: encryptedData.length,
|
|
475
|
-
chunks: 1,
|
|
476
|
-
compressed
|
|
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
|
|
477
491
|
});
|
|
478
|
-
|
|
479
|
-
this.db.addChunk(fileId, 0, result.messageId.toString(), fileData.length);
|
|
480
|
-
this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
481
|
-
.run(result.fileId, fileId, 0);
|
|
482
|
-
|
|
483
|
-
// Cleanup
|
|
484
|
-
fs.unlinkSync(tempPath);
|
|
485
|
-
try { fs.rmdirSync(tempDir); } catch (e) { }
|
|
486
492
|
}
|
|
487
493
|
|
|
488
494
|
getCached(filename) {
|
|
489
|
-
const entry = fileCache.get(filename);
|
|
495
|
+
const entry = this.fileCache.get(filename);
|
|
490
496
|
if (!entry) return null;
|
|
491
497
|
|
|
492
498
|
if (Date.now() - entry.timestamp > CACHE_TTL) {
|
|
@@ -494,7 +500,7 @@ export class TelegramFS {
|
|
|
494
500
|
try {
|
|
495
501
|
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
496
502
|
} catch (e) { }
|
|
497
|
-
fileCache.delete(filename);
|
|
503
|
+
this.fileCache.delete(filename);
|
|
498
504
|
return null;
|
|
499
505
|
}
|
|
500
506
|
|
|
@@ -504,19 +510,36 @@ export class TelegramFS {
|
|
|
504
510
|
}
|
|
505
511
|
|
|
506
512
|
setCache(filename, cachePath) {
|
|
507
|
-
|
|
513
|
+
// Evict oldest entry if cache is full
|
|
514
|
+
if (this.fileCache.size >= CACHE_MAX_ENTRIES) {
|
|
515
|
+
let oldestKey = null;
|
|
516
|
+
let oldestTime = Infinity;
|
|
517
|
+
for (const [key, entry] of this.fileCache) {
|
|
518
|
+
if (entry.timestamp < oldestTime) {
|
|
519
|
+
oldestTime = entry.timestamp;
|
|
520
|
+
oldestKey = key;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (oldestKey) {
|
|
524
|
+
const evicted = this.fileCache.get(oldestKey);
|
|
525
|
+
try { if (fs.existsSync(evicted.path)) fs.unlinkSync(evicted.path); } catch (e) { }
|
|
526
|
+
this.fileCache.delete(oldestKey);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
this.fileCache.set(filename, {
|
|
508
531
|
path: cachePath,
|
|
509
532
|
timestamp: Date.now()
|
|
510
533
|
});
|
|
511
534
|
}
|
|
512
535
|
|
|
513
536
|
invalidateCache(filename) {
|
|
514
|
-
const entry = fileCache.get(filename);
|
|
537
|
+
const entry = this.fileCache.get(filename);
|
|
515
538
|
if (entry) {
|
|
516
539
|
try {
|
|
517
540
|
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
518
541
|
} catch (e) { }
|
|
519
|
-
fileCache.delete(filename);
|
|
542
|
+
this.fileCache.delete(filename);
|
|
520
543
|
}
|
|
521
544
|
}
|
|
522
545
|
|
|
@@ -529,6 +552,17 @@ export class TelegramFS {
|
|
|
529
552
|
fs.mkdirSync(this.mountPoint, { recursive: true });
|
|
530
553
|
}
|
|
531
554
|
|
|
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
|
+
|
|
532
566
|
const ops = {
|
|
533
567
|
getattr: this.getattr.bind(this),
|
|
534
568
|
readdir: this.readdir.bind(this),
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import path from 'path';
|
|
|
8
8
|
import { pipeline } from 'stream/promises';
|
|
9
9
|
import { Encryptor, hashFile } from './crypto/encryption.js';
|
|
10
10
|
import { Compressor } from './utils/compression.js';
|
|
11
|
-
import {
|
|
11
|
+
import { createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
|
|
12
12
|
import { TelegramClient } from './telegram/client.js';
|
|
13
13
|
import { FileIndex } from './db/index.js';
|
|
14
14
|
|
|
@@ -72,15 +72,23 @@ export async function processFile(filePath, options) {
|
|
|
72
72
|
if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
|
|
73
73
|
let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
|
|
74
74
|
|
|
75
|
-
// Register file in DB
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
// Register file in DB inside a transaction for atomicity
|
|
76
|
+
db.db.exec('BEGIN');
|
|
77
|
+
let fileId;
|
|
78
|
+
try {
|
|
79
|
+
fileId = db.addFile({
|
|
80
|
+
filename,
|
|
81
|
+
hash,
|
|
82
|
+
originalSize,
|
|
83
|
+
storedSize: 0, // Will update later
|
|
84
|
+
chunks: estimatedChunks,
|
|
85
|
+
compressed
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
db.db.exec('ROLLBACK');
|
|
89
|
+
db.close();
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
84
92
|
|
|
85
93
|
onProgress?.('Processing and uploading streams...');
|
|
86
94
|
let uploadedBytes = 0;
|
|
@@ -162,22 +170,33 @@ export async function processFile(filePath, options) {
|
|
|
162
170
|
}
|
|
163
171
|
});
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
try {
|
|
174
|
+
const readStream = fs.createReadStream(filePath);
|
|
166
175
|
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
// Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
|
|
177
|
+
await pipeline(readStream, compressStream, encryptStream, chunkingStream);
|
|
178
|
+
} catch (pipelineErr) {
|
|
179
|
+
// Pipeline failed — roll back the DB transaction so no orphaned rows remain
|
|
180
|
+
try { db.db.exec('ROLLBACK'); } catch (e) { /* already rolled back */ }
|
|
181
|
+
db.close();
|
|
182
|
+
throw pipelineErr;
|
|
183
|
+
}
|
|
169
184
|
|
|
170
185
|
// Update the DB with the final accurate values
|
|
171
186
|
db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
|
|
172
187
|
.run(totalStoredSize, chunkIndex, fileId);
|
|
173
188
|
|
|
189
|
+
// Commit the transaction — all DB rows are now permanent
|
|
190
|
+
db.db.exec('COMMIT');
|
|
191
|
+
|
|
174
192
|
db.close();
|
|
175
193
|
|
|
176
|
-
// Clean up temp dir
|
|
194
|
+
// Clean up temp dir (only if empty)
|
|
177
195
|
try {
|
|
178
|
-
fs.
|
|
196
|
+
const remaining = fs.readdirSync(tempDir);
|
|
197
|
+
if (remaining.length === 0) fs.rmdirSync(tempDir);
|
|
179
198
|
} catch (e) {
|
|
180
|
-
// Ignore
|
|
199
|
+
// Ignore cleanup errors
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
return {
|
|
@@ -292,8 +311,16 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
292
311
|
|
|
293
312
|
const finalStats = fs.statSync(outputPath);
|
|
294
313
|
|
|
314
|
+
// Verify file integrity by comparing hash
|
|
315
|
+
onProgress?.('Verifying file integrity...');
|
|
316
|
+
const downloadedHash = await hashFile(outputPath);
|
|
317
|
+
if (fileRecord.hash && downloadedHash !== fileRecord.hash) {
|
|
318
|
+
throw new Error(`Integrity check failed: expected hash ${fileRecord.hash.substring(0, 12)}..., got ${downloadedHash.substring(0, 12)}...`);
|
|
319
|
+
}
|
|
320
|
+
|
|
295
321
|
return {
|
|
296
322
|
path: outputPath,
|
|
297
|
-
size: finalStats.size
|
|
323
|
+
size: finalStats.size,
|
|
324
|
+
verified: true
|
|
298
325
|
};
|
|
299
326
|
}
|
package/src/share/server.js
CHANGED
|
@@ -57,6 +57,25 @@ function formatTimeLeft(expiresAt) {
|
|
|
57
57
|
return `${minutes}m`;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Escape HTML special characters to prevent XSS
|
|
62
|
+
*/
|
|
63
|
+
function escapeHtml(str) {
|
|
64
|
+
return String(str)
|
|
65
|
+
.replace(/&/g, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
.replace(/"/g, '"')
|
|
69
|
+
.replace(/'/g, ''');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize filename for Content-Disposition header (RFC 6266)
|
|
74
|
+
*/
|
|
75
|
+
function sanitizeFilenameForHeader(filename) {
|
|
76
|
+
return filename.replace(/["\\\r\n]/g, '_');
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
/**
|
|
61
80
|
* Generate the download HTML page
|
|
62
81
|
*/
|
|
@@ -69,6 +88,7 @@ function generateDownloadPage(share, fileRecord) {
|
|
|
69
88
|
: fileSize > 1024
|
|
70
89
|
? `${(fileSize / 1024).toFixed(1)} KB`
|
|
71
90
|
: `${fileSize} B`;
|
|
91
|
+
const safeFilename = escapeHtml(fileRecord.filename);
|
|
72
92
|
|
|
73
93
|
return `<!DOCTYPE html>
|
|
74
94
|
<html lang="en">
|
|
@@ -146,7 +166,7 @@ function generateDownloadPage(share, fileRecord) {
|
|
|
146
166
|
<div class="card">
|
|
147
167
|
<div class="icon">🔐</div>
|
|
148
168
|
<h1>Secure File Share</h1>
|
|
149
|
-
<div class="filename">${
|
|
169
|
+
<div class="filename">${safeFilename}</div>
|
|
150
170
|
<div class="meta">
|
|
151
171
|
<span>📦 ${sizeStr}</span>
|
|
152
172
|
<span>⏳ ${timeLeft}</span>
|
|
@@ -241,16 +261,20 @@ export class ShareServer {
|
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
/**
|
|
244
|
-
*
|
|
264
|
+
* Prepare download streams from Telegram — fetches and verifies
|
|
265
|
+
* the first chunk before returning, so the caller can decide
|
|
266
|
+
* whether to commit to a 200 response.
|
|
267
|
+
*
|
|
268
|
+
* Returns: { downloadStream, decryptStream, decompressStream }
|
|
245
269
|
*/
|
|
246
|
-
async
|
|
270
|
+
async prepareDownloadStreams(fileRecord) {
|
|
247
271
|
const chunks = this.db.getChunks(fileRecord.id);
|
|
248
272
|
if (chunks.length === 0) throw new Error('No chunks found');
|
|
249
273
|
|
|
250
274
|
// Pre-sort chunks by index so we download them in correct order
|
|
251
275
|
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
252
276
|
|
|
253
|
-
//
|
|
277
|
+
// Fetch and verify the first chunk eagerly
|
|
254
278
|
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
255
279
|
const header = parseHeader(firstChunkData);
|
|
256
280
|
let wasCompressed = header.compressed;
|
|
@@ -259,9 +283,7 @@ export class ShareServer {
|
|
|
259
283
|
const decryptStream = this.encryptor.getDecryptStream();
|
|
260
284
|
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
261
285
|
|
|
262
|
-
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
263
286
|
const { Readable } = await import('stream');
|
|
264
|
-
const { pipeline } = await import('stream/promises');
|
|
265
287
|
|
|
266
288
|
const self = this;
|
|
267
289
|
let currentChunkIndex = 0;
|
|
@@ -296,8 +318,7 @@ export class ShareServer {
|
|
|
296
318
|
}
|
|
297
319
|
});
|
|
298
320
|
|
|
299
|
-
|
|
300
|
-
await pipeline(downloadStream, decryptStream, decompressStream, res);
|
|
321
|
+
return { downloadStream, decryptStream, decompressStream };
|
|
301
322
|
}
|
|
302
323
|
|
|
303
324
|
/**
|
|
@@ -378,15 +399,33 @@ export class ShareServer {
|
|
|
378
399
|
'.mp3': 'audio/mpeg'
|
|
379
400
|
};
|
|
380
401
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
402
|
+
const safeName = sanitizeFilenameForHeader(fileRecord.filename);
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
// Verify first chunk can be fetched and decrypted BEFORE sending 200 OK.
|
|
406
|
+
// This prevents sending a truncated/corrupt file on Telegram or decrypt failure.
|
|
407
|
+
const { downloadStream, decryptStream, decompressStream } = await this.prepareDownloadStreams(fileRecord);
|
|
408
|
+
|
|
409
|
+
// First chunk verified — commit to the 200 response
|
|
410
|
+
res.writeHead(200, {
|
|
411
|
+
'Content-Type': contentType,
|
|
412
|
+
'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
|
|
413
|
+
'Transfer-Encoding': 'chunked'
|
|
414
|
+
});
|
|
381
415
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
'
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
416
|
+
const { pipeline } = await import('stream/promises');
|
|
417
|
+
await pipeline(downloadStream, decryptStream, decompressStream, res);
|
|
418
|
+
} catch (streamErr) {
|
|
419
|
+
console.error('Share stream error:', streamErr.message);
|
|
420
|
+
// Headers not yet sent — we can still return a proper error
|
|
421
|
+
if (!res.headersSent) {
|
|
422
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
423
|
+
res.end('Download failed — file could not be decrypted or fetched.');
|
|
424
|
+
} else {
|
|
425
|
+
// Headers already sent (shouldn't happen now, but safety net)
|
|
426
|
+
res.end();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
390
429
|
|
|
391
430
|
} catch (err) {
|
|
392
431
|
console.error('Share server error:', err.message);
|