@nightowne/tas-cli 1.1.1 → 2.1.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 +102 -97
- package/package.json +3 -3
- package/src/cli.js +212 -147
- package/src/crypto/encryption.js +128 -0
- package/src/db/index.js +86 -0
- package/src/fuse/mount.js +104 -40
- package/src/index.js +173 -103
- package/src/share/server.js +441 -0
- package/src/sync/sync.js +54 -35
- package/src/telegram/client.js +45 -17
- package/src/utils/chunker.js +11 -1
- package/src/utils/cli-helpers.js +145 -0
- package/src/utils/compression.js +30 -0
- package/src/utils/throttle.js +26 -0
package/src/db/index.js
CHANGED
|
@@ -92,6 +92,22 @@ export class FileIndex {
|
|
|
92
92
|
);
|
|
93
93
|
`);
|
|
94
94
|
|
|
95
|
+
// Create shares table for temporary file sharing
|
|
96
|
+
this.db.exec(`
|
|
97
|
+
CREATE TABLE IF NOT EXISTS shares (
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
file_id INTEGER NOT NULL,
|
|
100
|
+
token TEXT UNIQUE NOT NULL,
|
|
101
|
+
expires_at TEXT NOT NULL,
|
|
102
|
+
max_downloads INTEGER NOT NULL DEFAULT 1,
|
|
103
|
+
download_count INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
105
|
+
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
CREATE INDEX IF NOT EXISTS idx_shares_token ON shares(token);
|
|
109
|
+
`);
|
|
110
|
+
|
|
95
111
|
// Create pending_uploads table for resume functionality
|
|
96
112
|
this.db.exec(`
|
|
97
113
|
CREATE TABLE IF NOT EXISTS pending_uploads (
|
|
@@ -492,6 +508,76 @@ export class FileIndex {
|
|
|
492
508
|
return stmt.get(hash);
|
|
493
509
|
}
|
|
494
510
|
|
|
511
|
+
// ============== SHARE METHODS ==============
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Create a share link for a file
|
|
515
|
+
*/
|
|
516
|
+
addShare(fileId, token, expiresAt, maxDownloads = 1) {
|
|
517
|
+
const stmt = this.db.prepare(`
|
|
518
|
+
INSERT INTO shares (file_id, token, expires_at, max_downloads)
|
|
519
|
+
VALUES (?, ?, ?, ?)
|
|
520
|
+
`);
|
|
521
|
+
const result = stmt.run(fileId, token, expiresAt, maxDownloads);
|
|
522
|
+
return result.lastInsertRowid;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Get a share by token (returns null if not found)
|
|
527
|
+
*/
|
|
528
|
+
getShare(token) {
|
|
529
|
+
const stmt = this.db.prepare(`
|
|
530
|
+
SELECT s.*, f.filename, f.original_size
|
|
531
|
+
FROM shares s
|
|
532
|
+
JOIN files f ON s.file_id = f.id
|
|
533
|
+
WHERE s.token = ?
|
|
534
|
+
`);
|
|
535
|
+
return stmt.get(token) || null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* List all active shares
|
|
540
|
+
*/
|
|
541
|
+
listShares() {
|
|
542
|
+
const stmt = this.db.prepare(`
|
|
543
|
+
SELECT s.*, f.filename, f.original_size
|
|
544
|
+
FROM shares s
|
|
545
|
+
JOIN files f ON s.file_id = f.id
|
|
546
|
+
ORDER BY s.created_at DESC
|
|
547
|
+
`);
|
|
548
|
+
return stmt.all();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Revoke (delete) a share by token
|
|
553
|
+
*/
|
|
554
|
+
revokeShare(token) {
|
|
555
|
+
const stmt = this.db.prepare('DELETE FROM shares WHERE token = ?');
|
|
556
|
+
const result = stmt.run(token);
|
|
557
|
+
return result.changes > 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Increment download count for a share
|
|
562
|
+
*/
|
|
563
|
+
incrementShareDownload(token) {
|
|
564
|
+
const stmt = this.db.prepare(`
|
|
565
|
+
UPDATE shares SET download_count = download_count + 1 WHERE token = ?
|
|
566
|
+
`);
|
|
567
|
+
stmt.run(token);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Remove expired shares
|
|
572
|
+
*/
|
|
573
|
+
cleanExpiredShares() {
|
|
574
|
+
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')
|
|
577
|
+
`);
|
|
578
|
+
return stmt.run().changes;
|
|
579
|
+
}
|
|
580
|
+
|
|
495
581
|
/**
|
|
496
582
|
* Close database connection
|
|
497
583
|
*/
|
package/src/fuse/mount.js
CHANGED
|
@@ -130,26 +130,35 @@ export class TelegramFS {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
|
-
* Read file contents
|
|
133
|
+
* Read file contents from disk cache
|
|
134
134
|
*/
|
|
135
135
|
async read(filepath, fd, buffer, length, position, cb) {
|
|
136
136
|
const filename = path.basename(filepath);
|
|
137
137
|
|
|
138
138
|
try {
|
|
139
|
+
// Check write buffers first
|
|
140
|
+
const wb = this.writeBuffers.get(filename);
|
|
141
|
+
if (wb) {
|
|
142
|
+
const slice = wb.data.subarray(position, position + length);
|
|
143
|
+
slice.copy(buffer);
|
|
144
|
+
return cb(slice.length);
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
// Check cache first
|
|
140
|
-
let
|
|
148
|
+
let cachedPath = this.getCached(filename);
|
|
141
149
|
|
|
142
|
-
if (!
|
|
143
|
-
// Download and
|
|
144
|
-
|
|
145
|
-
this.setCache(filename,
|
|
150
|
+
if (!cachedPath) {
|
|
151
|
+
// Download, decrypt, and save to disk cache
|
|
152
|
+
cachedPath = await this.downloadFileToCache(filename);
|
|
153
|
+
this.setCache(filename, cachedPath);
|
|
146
154
|
}
|
|
147
155
|
|
|
148
|
-
// Copy requested portion to buffer
|
|
149
|
-
const
|
|
150
|
-
|
|
156
|
+
// Copy requested portion to buffer from disk
|
|
157
|
+
const fdDisk = fs.openSync(cachedPath, 'r');
|
|
158
|
+
const bytesRead = fs.readSync(fdDisk, buffer, 0, length, position);
|
|
159
|
+
fs.closeSync(fdDisk);
|
|
151
160
|
|
|
152
|
-
return cb(
|
|
161
|
+
return cb(bytesRead);
|
|
153
162
|
} catch (err) {
|
|
154
163
|
console.error('Read error:', err.message);
|
|
155
164
|
return cb(Fuse.EIO);
|
|
@@ -305,10 +314,10 @@ export class TelegramFS {
|
|
|
305
314
|
|
|
306
315
|
// Get or load into write buffer
|
|
307
316
|
if (!this.writeBuffers.has(filename)) {
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
317
|
+
const cachedPath = this.getCached(filename);
|
|
318
|
+
if (cachedPath) {
|
|
310
319
|
this.writeBuffers.set(filename, {
|
|
311
|
-
data:
|
|
320
|
+
data: fs.readFileSync(cachedPath), // Note: RAM buffer here could be big, but it's okay for truncate/writes right now
|
|
312
321
|
modified: true
|
|
313
322
|
});
|
|
314
323
|
} else {
|
|
@@ -335,38 +344,81 @@ export class TelegramFS {
|
|
|
335
344
|
|
|
336
345
|
// ============== Helper Methods ==============
|
|
337
346
|
|
|
338
|
-
async
|
|
347
|
+
async downloadFileToCache(filename) {
|
|
339
348
|
const file = this.db.findByName(filename);
|
|
340
349
|
if (!file) throw new Error('File not found');
|
|
341
350
|
|
|
351
|
+
const cacheDir = path.join(this.dataDir, 'cache');
|
|
352
|
+
if (!fs.existsSync(cacheDir)) {
|
|
353
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const outputPath = path.join(cacheDir, file.hash);
|
|
357
|
+
|
|
358
|
+
// If it's already fully downloaded and cached on disk, return path
|
|
359
|
+
if (fs.existsSync(outputPath)) {
|
|
360
|
+
const stats = fs.statSync(outputPath);
|
|
361
|
+
if (stats.size === file.original_size) {
|
|
362
|
+
return outputPath;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
342
366
|
const chunks = this.db.getChunks(file.id);
|
|
343
367
|
if (chunks.length === 0) throw new Error('No chunks found');
|
|
344
368
|
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
369
|
+
// Pre-sort chunks
|
|
370
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
371
|
+
|
|
372
|
+
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
373
|
+
const header = parseHeader(firstChunkData);
|
|
374
|
+
let wasCompressed = header.compressed;
|
|
375
|
+
|
|
376
|
+
const decryptStream = this.encryptor.getDecryptStream();
|
|
377
|
+
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
378
|
+
|
|
379
|
+
const { Readable } = await import('stream');
|
|
380
|
+
const { pipeline } = await import('stream/promises');
|
|
381
|
+
|
|
382
|
+
const self = this;
|
|
383
|
+
let currentChunkIndex = 0;
|
|
384
|
+
let preloadedFirstChunk = firstChunkData;
|
|
385
|
+
|
|
386
|
+
const downloadStream = new Readable({
|
|
387
|
+
async read() {
|
|
388
|
+
try {
|
|
389
|
+
if (currentChunkIndex >= chunks.length) {
|
|
390
|
+
this.push(null);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const chunk = chunks[currentChunkIndex];
|
|
395
|
+
let data;
|
|
396
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
397
|
+
data = preloadedFirstChunk;
|
|
398
|
+
preloadedFirstChunk = null;
|
|
399
|
+
} else {
|
|
400
|
+
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
404
|
+
this.push(payload);
|
|
405
|
+
currentChunkIndex++;
|
|
406
|
+
} catch (err) {
|
|
407
|
+
this.destroy(err);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
352
411
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
data: payload,
|
|
356
|
-
compressed: header.compressed
|
|
357
|
-
});
|
|
358
|
-
}
|
|
412
|
+
const tmpOutputPath = outputPath + '.tmp';
|
|
413
|
+
const writeStream = fs.createWriteStream(tmpOutputPath);
|
|
359
414
|
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
const encryptedData = Buffer.concat(downloadedChunks.map(c => c.data));
|
|
415
|
+
// Pipeline: Telegram -> Decrypt -> Decompress -> Disk Cache
|
|
416
|
+
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
363
417
|
|
|
364
|
-
//
|
|
365
|
-
|
|
418
|
+
// Rename to final atomic path
|
|
419
|
+
fs.renameSync(tmpOutputPath, outputPath);
|
|
366
420
|
|
|
367
|
-
|
|
368
|
-
const wasCompressed = downloadedChunks[0].compressed;
|
|
369
|
-
return await this.compressor.decompress(compressedData, wasCompressed);
|
|
421
|
+
return outputPath;
|
|
370
422
|
}
|
|
371
423
|
|
|
372
424
|
async uploadFile(filename, data) {
|
|
@@ -399,7 +451,7 @@ export class TelegramFS {
|
|
|
399
451
|
const encryptedData = this.encryptor.encrypt(compressedData);
|
|
400
452
|
|
|
401
453
|
// Create temp file with header
|
|
402
|
-
const tempDir = path.join(this.dataDir, 'tmp');
|
|
454
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
|
|
403
455
|
if (!fs.existsSync(tempDir)) {
|
|
404
456
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
405
457
|
}
|
|
@@ -438,22 +490,34 @@ export class TelegramFS {
|
|
|
438
490
|
if (!entry) return null;
|
|
439
491
|
|
|
440
492
|
if (Date.now() - entry.timestamp > CACHE_TTL) {
|
|
493
|
+
// Expired, delete the file if possible
|
|
494
|
+
try {
|
|
495
|
+
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
496
|
+
} catch (e) { }
|
|
441
497
|
fileCache.delete(filename);
|
|
442
498
|
return null;
|
|
443
499
|
}
|
|
444
500
|
|
|
445
|
-
|
|
501
|
+
// Extend cache TTL on read
|
|
502
|
+
entry.timestamp = Date.now();
|
|
503
|
+
return entry.path;
|
|
446
504
|
}
|
|
447
505
|
|
|
448
|
-
setCache(filename,
|
|
506
|
+
setCache(filename, cachePath) {
|
|
449
507
|
fileCache.set(filename, {
|
|
450
|
-
|
|
508
|
+
path: cachePath,
|
|
451
509
|
timestamp: Date.now()
|
|
452
510
|
});
|
|
453
511
|
}
|
|
454
512
|
|
|
455
513
|
invalidateCache(filename) {
|
|
456
|
-
fileCache.
|
|
514
|
+
const entry = fileCache.get(filename);
|
|
515
|
+
if (entry) {
|
|
516
|
+
try {
|
|
517
|
+
if (fs.existsSync(entry.path)) fs.unlinkSync(entry.path);
|
|
518
|
+
} catch (e) { }
|
|
519
|
+
fileCache.delete(filename);
|
|
520
|
+
}
|
|
457
521
|
}
|
|
458
522
|
|
|
459
523
|
/**
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
import { pipeline } from 'stream/promises';
|
|
8
9
|
import { Encryptor, hashFile } from './crypto/encryption.js';
|
|
9
10
|
import { Compressor } from './utils/compression.js';
|
|
10
11
|
import { Chunker, createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
|
|
@@ -23,10 +24,10 @@ export async function processFile(filePath, options) {
|
|
|
23
24
|
|
|
24
25
|
onProgress?.('Reading file...');
|
|
25
26
|
|
|
26
|
-
// Read file
|
|
27
|
+
// Read file initially just to get size
|
|
27
28
|
const filename = customName || path.basename(filePath);
|
|
28
|
-
const
|
|
29
|
-
const originalSize =
|
|
29
|
+
const stats = fs.statSync(filePath);
|
|
30
|
+
const originalSize = stats.size;
|
|
30
31
|
|
|
31
32
|
// Calculate hash
|
|
32
33
|
onProgress?.('Calculating hash...');
|
|
@@ -41,99 +42,134 @@ export async function processFile(filePath, options) {
|
|
|
41
42
|
throw new Error('File already uploaded (duplicate hash)');
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
//
|
|
45
|
-
onProgress?.('Compressing...');
|
|
45
|
+
// Prepare processing components
|
|
46
46
|
const compressor = new Compressor();
|
|
47
|
-
const {
|
|
47
|
+
const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
|
|
48
|
+
const flags = compressed ? 1 : 0;
|
|
48
49
|
|
|
49
|
-
// Encrypt
|
|
50
|
-
onProgress?.('Encrypting...');
|
|
51
50
|
const encryptor = new Encryptor(password);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
// Chunk if needed (Telegram bot limit ~50MB per file)
|
|
55
|
-
onProgress?.('Preparing chunks...');
|
|
56
|
-
const chunks = [];
|
|
57
|
-
const numChunks = Math.ceil(encryptedData.length / TELEGRAM_CHUNK_SIZE);
|
|
58
|
-
|
|
59
|
-
for (let i = 0; i < numChunks; i++) {
|
|
60
|
-
const start = i * TELEGRAM_CHUNK_SIZE;
|
|
61
|
-
const end = Math.min(start + TELEGRAM_CHUNK_SIZE, encryptedData.length);
|
|
62
|
-
chunks.push({
|
|
63
|
-
index: i,
|
|
64
|
-
total: numChunks,
|
|
65
|
-
data: encryptedData.subarray(start, end)
|
|
66
|
-
});
|
|
67
|
-
}
|
|
51
|
+
const encryptStream = encryptor.getEncryptStream();
|
|
68
52
|
|
|
69
|
-
|
|
70
|
-
const tempDir = path.join(dataDir, 'tmp');
|
|
53
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
71
54
|
if (!fs.existsSync(tempDir)) {
|
|
72
55
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
73
56
|
}
|
|
74
57
|
|
|
75
|
-
const chunkFiles = [];
|
|
76
|
-
const flags = compressed ? 1 : 0;
|
|
77
|
-
|
|
78
|
-
for (const chunk of chunks) {
|
|
79
|
-
const header = createHeader(filename, originalSize, chunk.index, chunk.total, flags);
|
|
80
|
-
const chunkData = Buffer.concat([header, chunk.data]);
|
|
81
|
-
|
|
82
|
-
const chunkFilename = chunks.length > 1
|
|
83
|
-
? `${hash.substring(0, 12)}.part${chunk.index}.tas`
|
|
84
|
-
: `${hash.substring(0, 12)}.tas`;
|
|
85
|
-
|
|
86
|
-
const chunkPath = path.join(tempDir, chunkFilename);
|
|
87
|
-
fs.writeFileSync(chunkPath, chunkData);
|
|
88
|
-
|
|
89
|
-
chunkFiles.push({
|
|
90
|
-
index: chunk.index,
|
|
91
|
-
path: chunkPath,
|
|
92
|
-
size: chunkData.length
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
58
|
// Connect to Telegram
|
|
97
59
|
onProgress?.('Connecting to Telegram...');
|
|
98
60
|
const client = new TelegramClient(dataDir);
|
|
99
61
|
await client.initialize(config.botToken);
|
|
100
62
|
client.setChatId(config.chatId);
|
|
101
63
|
|
|
102
|
-
//
|
|
64
|
+
// We will stream through a custom Writable chunker
|
|
65
|
+
const { Writable } = await import('stream');
|
|
66
|
+
|
|
67
|
+
// First pass estimation (for calculating total chunks and progress)
|
|
68
|
+
// We don't know the exact final size due to compression and encryption overhead,
|
|
69
|
+
// so we'll estimate total chunks and update it if needed.
|
|
70
|
+
// For small files < 49MB we assume 1 chunk.
|
|
71
|
+
let estimatedSize = compressed ? originalSize : originalSize + 128; // Add encryption overhead
|
|
72
|
+
if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
|
|
73
|
+
let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
|
|
74
|
+
|
|
75
|
+
// Register file in DB
|
|
103
76
|
const fileId = db.addFile({
|
|
104
77
|
filename,
|
|
105
78
|
hash,
|
|
106
79
|
originalSize,
|
|
107
|
-
storedSize:
|
|
108
|
-
chunks:
|
|
80
|
+
storedSize: 0, // Will update later
|
|
81
|
+
chunks: estimatedChunks,
|
|
109
82
|
compressed
|
|
110
83
|
});
|
|
111
84
|
|
|
85
|
+
onProgress?.('Processing and uploading streams...');
|
|
112
86
|
let uploadedBytes = 0;
|
|
113
|
-
|
|
87
|
+
let chunkIndex = 0;
|
|
88
|
+
|
|
89
|
+
let currentChunkBuffer = Buffer.alloc(0);
|
|
90
|
+
let totalStoredSize = 0;
|
|
91
|
+
|
|
92
|
+
// Helper to upload a single chunk
|
|
93
|
+
const uploadCurrentChunk = async (isFinal = false) => {
|
|
94
|
+
if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
|
|
95
|
+
if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
|
|
96
|
+
|
|
97
|
+
// At this point we know if it's the final chunk, so we know the total chunks
|
|
98
|
+
const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
|
|
99
|
+
|
|
100
|
+
const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
|
|
101
|
+
const chunkData = Buffer.concat([header, currentChunkBuffer]);
|
|
114
102
|
|
|
115
|
-
|
|
116
|
-
|
|
103
|
+
const chunkFilename = totalChunks > 1
|
|
104
|
+
? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
|
|
105
|
+
: `${hash.substring(0, 12)}.tas`;
|
|
106
|
+
|
|
107
|
+
const chunkPath = path.join(tempDir, chunkFilename);
|
|
108
|
+
fs.writeFileSync(chunkPath, chunkData);
|
|
117
109
|
|
|
118
|
-
const caption =
|
|
119
|
-
? `📦 ${filename} (${
|
|
110
|
+
const caption = totalChunks > 1
|
|
111
|
+
? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
|
|
120
112
|
: `📦 ${filename}`;
|
|
121
113
|
|
|
122
|
-
|
|
114
|
+
onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
|
|
115
|
+
|
|
116
|
+
const result = await client.sendFile(chunkPath, caption);
|
|
123
117
|
|
|
124
|
-
uploadedBytes +=
|
|
125
|
-
|
|
118
|
+
uploadedBytes += chunkData.length;
|
|
119
|
+
totalStoredSize += currentChunkBuffer.length;
|
|
126
120
|
|
|
127
|
-
|
|
128
|
-
db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
|
|
121
|
+
onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
|
|
129
122
|
|
|
130
|
-
//
|
|
123
|
+
// Store file_id
|
|
124
|
+
db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
|
|
131
125
|
db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
132
|
-
.run(result.fileId, fileId,
|
|
126
|
+
.run(result.fileId, fileId, chunkIndex);
|
|
133
127
|
|
|
134
|
-
// Clean up temp file
|
|
135
|
-
fs.unlinkSync(
|
|
136
|
-
|
|
128
|
+
// Clean up temp file immediately to save disk space
|
|
129
|
+
fs.unlinkSync(chunkPath);
|
|
130
|
+
|
|
131
|
+
chunkIndex++;
|
|
132
|
+
currentChunkBuffer = Buffer.alloc(0);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const chunkingStream = new Writable({
|
|
136
|
+
async write(chunk, encoding, callback) {
|
|
137
|
+
currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
|
|
138
|
+
|
|
139
|
+
// If we exceeded the chunk limit, flush it
|
|
140
|
+
if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
|
|
141
|
+
const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
|
|
142
|
+
currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await uploadCurrentChunk(false);
|
|
146
|
+
currentChunkBuffer = overflow; // carry over
|
|
147
|
+
callback();
|
|
148
|
+
} catch (err) {
|
|
149
|
+
callback(err);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
callback();
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
async final(callback) {
|
|
156
|
+
try {
|
|
157
|
+
await uploadCurrentChunk(true);
|
|
158
|
+
callback();
|
|
159
|
+
} catch (err) {
|
|
160
|
+
callback(err);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const readStream = fs.createReadStream(filePath);
|
|
166
|
+
|
|
167
|
+
// Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
|
|
168
|
+
await pipeline(readStream, compressStream, encryptStream, chunkingStream);
|
|
169
|
+
|
|
170
|
+
// Update the DB with the final accurate values
|
|
171
|
+
db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
|
|
172
|
+
.run(totalStoredSize, chunkIndex, fileId);
|
|
137
173
|
|
|
138
174
|
db.close();
|
|
139
175
|
|
|
@@ -148,8 +184,8 @@ export async function processFile(filePath, options) {
|
|
|
148
184
|
filename,
|
|
149
185
|
hash,
|
|
150
186
|
originalSize,
|
|
151
|
-
storedSize:
|
|
152
|
-
chunks:
|
|
187
|
+
storedSize: totalStoredSize,
|
|
188
|
+
chunks: chunkIndex,
|
|
153
189
|
compressed
|
|
154
190
|
};
|
|
155
191
|
}
|
|
@@ -173,57 +209,91 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
173
209
|
throw new Error('No chunk metadata found for this file');
|
|
174
210
|
}
|
|
175
211
|
|
|
212
|
+
// Prepare components
|
|
213
|
+
const encryptor = new Encryptor(password);
|
|
214
|
+
const decryptStream = encryptor.getDecryptStream();
|
|
215
|
+
|
|
216
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
217
|
+
if (!fs.existsSync(tempDir)) {
|
|
218
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
176
221
|
// Connect to Telegram
|
|
177
222
|
const client = new TelegramClient(dataDir);
|
|
178
223
|
await client.initialize(config.botToken);
|
|
179
224
|
client.setChatId(config.chatId);
|
|
180
225
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
226
|
+
// Get total size from first chunk's header, or from DB
|
|
227
|
+
const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
|
|
228
|
+
const header = parseHeader(firstChunkData);
|
|
185
229
|
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
// Total original uncompressed size
|
|
231
|
+
let expectedOriginalSize = header.originalSize;
|
|
232
|
+
let wasCompressed = header.compressed;
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
234
|
+
const compressor = new Compressor();
|
|
235
|
+
const decompressStream = compressor.getDecompressStream(wasCompressed);
|
|
192
236
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
237
|
+
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
238
|
+
// and push them into the decryption pipeline.
|
|
239
|
+
const { Readable } = await import('stream');
|
|
196
240
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
total: header.totalChunks,
|
|
200
|
-
data: payload,
|
|
201
|
-
compressed: header.compressed
|
|
202
|
-
});
|
|
203
|
-
}
|
|
241
|
+
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
242
|
+
let downloadedBytes = 0;
|
|
204
243
|
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
244
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
245
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
246
|
+
|
|
247
|
+
let currentChunkIndex = 0;
|
|
248
|
+
|
|
249
|
+
// We already downloaded the first chunk to inspect its header, we shouldn't discard it.
|
|
250
|
+
let preloadedFirstChunk = firstChunkData;
|
|
251
|
+
|
|
252
|
+
const downloadStream = new Readable({
|
|
253
|
+
async read() {
|
|
254
|
+
try {
|
|
255
|
+
if (currentChunkIndex >= chunks.length) {
|
|
256
|
+
this.push(null); // End of stream
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const chunk = chunks[currentChunkIndex];
|
|
261
|
+
onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
|
|
262
|
+
|
|
263
|
+
let data;
|
|
264
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
265
|
+
data = preloadedFirstChunk;
|
|
266
|
+
preloadedFirstChunk = null;
|
|
267
|
+
} else {
|
|
268
|
+
data = await client.downloadFile(chunk.file_telegram_id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
downloadedBytes += data.length;
|
|
272
|
+
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
273
|
+
|
|
274
|
+
// Strip header before pushing
|
|
275
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
276
|
+
this.push(payload);
|
|
277
|
+
|
|
278
|
+
currentChunkIndex++;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
this.destroy(err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
209
284
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const encryptor = new Encryptor(password);
|
|
213
|
-
const compressedData = encryptor.decrypt(encryptedData);
|
|
285
|
+
const writeStream = fs.createWriteStream(outputPath);
|
|
286
|
+
const { pipeline } = await import('stream/promises');
|
|
214
287
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const originalData = await compressor.decompress(compressedData, wasCompressed);
|
|
288
|
+
onProgress?.('Decrypting, decompressing, and writing file...');
|
|
289
|
+
|
|
290
|
+
// Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
|
|
291
|
+
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
220
292
|
|
|
221
|
-
|
|
222
|
-
onProgress?.('Writing file...');
|
|
223
|
-
fs.writeFileSync(outputPath, originalData);
|
|
293
|
+
const finalStats = fs.statSync(outputPath);
|
|
224
294
|
|
|
225
295
|
return {
|
|
226
296
|
path: outputPath,
|
|
227
|
-
size:
|
|
297
|
+
size: finalStats.size
|
|
228
298
|
};
|
|
229
299
|
}
|