@nightowne/tas-cli 2.0.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 +326 -132
- package/package.json +4 -2
- package/src/cli.js +199 -24
- package/src/crypto/encryption.js +153 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +253 -155
- package/src/index.js +209 -112
- package/src/share/server.js +100 -32
- package/src/sync/sync.js +211 -56
- package/src/telegram/client.js +102 -26
- package/src/utils/branding.js +10 -3
- package/src/utils/chunker.js +15 -3
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/compression.js +30 -0
- package/src/utils/progress.js +3 -3
- package/src/utils/throttle.js +26 -0
package/src/index.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
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
|
-
import {
|
|
11
|
+
import { createHeader, parseHeader, HEADER_SIZE } from './utils/chunker.js';
|
|
11
12
|
import { TelegramClient } from './telegram/client.js';
|
|
12
13
|
import { FileIndex } from './db/index.js';
|
|
13
14
|
|
|
@@ -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,115 +42,169 @@ 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
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 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
|
+
}
|
|
111
92
|
|
|
93
|
+
onProgress?.('Processing and uploading streams...');
|
|
112
94
|
let uploadedBytes = 0;
|
|
113
|
-
|
|
95
|
+
let chunkIndex = 0;
|
|
96
|
+
|
|
97
|
+
let currentChunkBuffer = Buffer.alloc(0);
|
|
98
|
+
let totalStoredSize = 0;
|
|
99
|
+
|
|
100
|
+
// Helper to upload a single chunk
|
|
101
|
+
const uploadCurrentChunk = async (isFinal = false) => {
|
|
102
|
+
if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
|
|
103
|
+
if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
|
|
104
|
+
|
|
105
|
+
// At this point we know if it's the final chunk, so we know the total chunks
|
|
106
|
+
const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
|
|
114
107
|
|
|
115
|
-
|
|
116
|
-
|
|
108
|
+
const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
|
|
109
|
+
const chunkData = Buffer.concat([header, currentChunkBuffer]);
|
|
117
110
|
|
|
118
|
-
const
|
|
119
|
-
?
|
|
111
|
+
const chunkFilename = totalChunks > 1
|
|
112
|
+
? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
|
|
113
|
+
: `${hash.substring(0, 12)}.tas`;
|
|
114
|
+
|
|
115
|
+
const chunkPath = path.join(tempDir, chunkFilename);
|
|
116
|
+
fs.writeFileSync(chunkPath, chunkData);
|
|
117
|
+
|
|
118
|
+
const caption = totalChunks > 1
|
|
119
|
+
? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
|
|
120
120
|
: `📦 ${filename}`;
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
|
|
123
|
+
|
|
124
|
+
const result = await client.sendFile(chunkPath, caption);
|
|
123
125
|
|
|
124
|
-
uploadedBytes +=
|
|
125
|
-
|
|
126
|
+
uploadedBytes += chunkData.length;
|
|
127
|
+
totalStoredSize += currentChunkBuffer.length;
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
db.addChunk(fileId, chunk.index, result.messageId.toString(), chunk.size);
|
|
129
|
+
onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
|
|
129
130
|
|
|
130
|
-
//
|
|
131
|
+
// Store file_id
|
|
132
|
+
db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
|
|
131
133
|
db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
132
|
-
.run(result.fileId, fileId,
|
|
134
|
+
.run(result.fileId, fileId, chunkIndex);
|
|
135
|
+
|
|
136
|
+
// Clean up temp file immediately to save disk space
|
|
137
|
+
fs.unlinkSync(chunkPath);
|
|
138
|
+
|
|
139
|
+
chunkIndex++;
|
|
140
|
+
currentChunkBuffer = Buffer.alloc(0);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const chunkingStream = new Writable({
|
|
144
|
+
async write(chunk, encoding, callback) {
|
|
145
|
+
currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
|
|
146
|
+
|
|
147
|
+
// If we exceeded the chunk limit, flush it
|
|
148
|
+
if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
|
|
149
|
+
const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
|
|
150
|
+
currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
await uploadCurrentChunk(false);
|
|
154
|
+
currentChunkBuffer = overflow; // carry over
|
|
155
|
+
callback();
|
|
156
|
+
} catch (err) {
|
|
157
|
+
callback(err);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
callback();
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
async final(callback) {
|
|
164
|
+
try {
|
|
165
|
+
await uploadCurrentChunk(true);
|
|
166
|
+
callback();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
callback(err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const readStream = fs.createReadStream(filePath);
|
|
133
175
|
|
|
134
|
-
//
|
|
135
|
-
|
|
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;
|
|
136
183
|
}
|
|
137
184
|
|
|
185
|
+
// Update the DB with the final accurate values
|
|
186
|
+
db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
|
|
187
|
+
.run(totalStoredSize, chunkIndex, fileId);
|
|
188
|
+
|
|
189
|
+
// Commit the transaction — all DB rows are now permanent
|
|
190
|
+
db.db.exec('COMMIT');
|
|
191
|
+
|
|
138
192
|
db.close();
|
|
139
193
|
|
|
140
|
-
// Clean up temp dir
|
|
194
|
+
// Clean up temp dir (only if empty)
|
|
141
195
|
try {
|
|
142
|
-
fs.
|
|
196
|
+
const remaining = fs.readdirSync(tempDir);
|
|
197
|
+
if (remaining.length === 0) fs.rmdirSync(tempDir);
|
|
143
198
|
} catch (e) {
|
|
144
|
-
// Ignore
|
|
199
|
+
// Ignore cleanup errors
|
|
145
200
|
}
|
|
146
201
|
|
|
147
202
|
return {
|
|
148
203
|
filename,
|
|
149
204
|
hash,
|
|
150
205
|
originalSize,
|
|
151
|
-
storedSize:
|
|
152
|
-
chunks:
|
|
206
|
+
storedSize: totalStoredSize,
|
|
207
|
+
chunks: chunkIndex,
|
|
153
208
|
compressed
|
|
154
209
|
};
|
|
155
210
|
}
|
|
@@ -173,57 +228,99 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
173
228
|
throw new Error('No chunk metadata found for this file');
|
|
174
229
|
}
|
|
175
230
|
|
|
231
|
+
// Prepare components
|
|
232
|
+
const encryptor = new Encryptor(password);
|
|
233
|
+
const decryptStream = encryptor.getDecryptStream();
|
|
234
|
+
|
|
235
|
+
const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
236
|
+
if (!fs.existsSync(tempDir)) {
|
|
237
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
176
240
|
// Connect to Telegram
|
|
177
241
|
const client = new TelegramClient(dataDir);
|
|
178
242
|
await client.initialize(config.botToken);
|
|
179
243
|
client.setChatId(config.chatId);
|
|
180
244
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
245
|
+
// Get total size from first chunk's header, or from DB
|
|
246
|
+
const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
|
|
247
|
+
const header = parseHeader(firstChunkData);
|
|
185
248
|
|
|
186
|
-
|
|
187
|
-
|
|
249
|
+
// Total original uncompressed size
|
|
250
|
+
let expectedOriginalSize = header.originalSize;
|
|
251
|
+
let wasCompressed = header.compressed;
|
|
188
252
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
253
|
+
const compressor = new Compressor();
|
|
254
|
+
const decompressStream = compressor.getDecompressStream(wasCompressed);
|
|
192
255
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
256
|
+
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
257
|
+
// and push them into the decryption pipeline.
|
|
258
|
+
const { Readable } = await import('stream');
|
|
196
259
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
total: header.totalChunks,
|
|
200
|
-
data: payload,
|
|
201
|
-
compressed: header.compressed
|
|
202
|
-
});
|
|
203
|
-
}
|
|
260
|
+
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
261
|
+
let downloadedBytes = 0;
|
|
204
262
|
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
263
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
264
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
265
|
+
|
|
266
|
+
let currentChunkIndex = 0;
|
|
267
|
+
|
|
268
|
+
// We already downloaded the first chunk to inspect its header, we shouldn't discard it.
|
|
269
|
+
let preloadedFirstChunk = firstChunkData;
|
|
270
|
+
|
|
271
|
+
const downloadStream = new Readable({
|
|
272
|
+
async read() {
|
|
273
|
+
try {
|
|
274
|
+
if (currentChunkIndex >= chunks.length) {
|
|
275
|
+
this.push(null); // End of stream
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const chunk = chunks[currentChunkIndex];
|
|
280
|
+
onProgress?.(`Downloading chunk ${chunk.chunk_index + 1}/${chunks.length}...`);
|
|
281
|
+
|
|
282
|
+
let data;
|
|
283
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
284
|
+
data = preloadedFirstChunk;
|
|
285
|
+
preloadedFirstChunk = null;
|
|
286
|
+
} else {
|
|
287
|
+
data = await client.downloadFile(chunk.file_telegram_id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
downloadedBytes += data.length;
|
|
291
|
+
onByteProgress?.({ downloaded: downloadedBytes, total: totalBytes, chunk: chunk.chunk_index + 1, totalChunks: chunks.length });
|
|
292
|
+
|
|
293
|
+
// Strip header before pushing
|
|
294
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
295
|
+
this.push(payload);
|
|
296
|
+
|
|
297
|
+
currentChunkIndex++;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
this.destroy(err);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
209
303
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const encryptor = new Encryptor(password);
|
|
213
|
-
const compressedData = encryptor.decrypt(encryptedData);
|
|
304
|
+
const writeStream = fs.createWriteStream(outputPath);
|
|
305
|
+
const { pipeline } = await import('stream/promises');
|
|
214
306
|
|
|
215
|
-
|
|
216
|
-
onProgress?.('Decompressing...');
|
|
217
|
-
const compressor = new Compressor();
|
|
218
|
-
const wasCompressed = downloadedChunks[0].compressed;
|
|
219
|
-
const originalData = await compressor.decompress(compressedData, wasCompressed);
|
|
307
|
+
onProgress?.('Decrypting, decompressing, and writing file...');
|
|
220
308
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
309
|
+
// Pipeline: Download from Telegram -> Decrypt -> Decompress -> Disk
|
|
310
|
+
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
311
|
+
|
|
312
|
+
const finalStats = fs.statSync(outputPath);
|
|
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
|
+
}
|
|
224
320
|
|
|
225
321
|
return {
|
|
226
322
|
path: outputPath,
|
|
227
|
-
size:
|
|
323
|
+
size: finalStats.size,
|
|
324
|
+
verified: true
|
|
228
325
|
};
|
|
229
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,33 +261,64 @@ 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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
274
|
+
// Pre-sort chunks by index so we download them in correct order
|
|
275
|
+
chunks.sort((a, b) => a.chunk_index - b.chunk_index);
|
|
276
|
+
|
|
277
|
+
// Fetch and verify the first chunk eagerly
|
|
278
|
+
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
279
|
+
const header = parseHeader(firstChunkData);
|
|
280
|
+
let wasCompressed = header.compressed;
|
|
281
|
+
|
|
282
|
+
// Prepare streams
|
|
283
|
+
const decryptStream = this.encryptor.getDecryptStream();
|
|
284
|
+
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
285
|
+
|
|
286
|
+
const { Readable } = await import('stream');
|
|
287
|
+
|
|
288
|
+
const self = this;
|
|
289
|
+
let currentChunkIndex = 0;
|
|
290
|
+
let preloadedFirstChunk = firstChunkData;
|
|
291
|
+
|
|
292
|
+
const downloadStream = new Readable({
|
|
293
|
+
async read() {
|
|
294
|
+
try {
|
|
295
|
+
if (currentChunkIndex >= chunks.length) {
|
|
296
|
+
this.push(null); // End of stream
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const chunk = chunks[currentChunkIndex];
|
|
301
|
+
let data;
|
|
302
|
+
|
|
303
|
+
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
304
|
+
data = preloadedFirstChunk;
|
|
305
|
+
preloadedFirstChunk = null;
|
|
306
|
+
} else {
|
|
307
|
+
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Strip header before pushing
|
|
311
|
+
const payload = data.subarray(HEADER_SIZE);
|
|
312
|
+
this.push(payload);
|
|
313
|
+
|
|
314
|
+
currentChunkIndex++;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.destroy(err);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
});
|
|
268
320
|
|
|
269
|
-
|
|
270
|
-
return await this.compressor.decompress(compressedData, wasCompressed);
|
|
321
|
+
return { downloadStream, decryptStream, decompressStream };
|
|
271
322
|
}
|
|
272
323
|
|
|
273
324
|
/**
|
|
@@ -330,9 +381,6 @@ export class ShareServer {
|
|
|
330
381
|
return;
|
|
331
382
|
}
|
|
332
383
|
|
|
333
|
-
// Download the file from Telegram, decrypt, and serve
|
|
334
|
-
const data = await this.downloadAndDecrypt(fileRecord);
|
|
335
|
-
|
|
336
384
|
// Increment download count
|
|
337
385
|
this.db.incrementShareDownload(token);
|
|
338
386
|
|
|
@@ -351,13 +399,33 @@ export class ShareServer {
|
|
|
351
399
|
'.mp3': 'audio/mpeg'
|
|
352
400
|
};
|
|
353
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
|
+
});
|
|
354
415
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
'
|
|
359
|
-
|
|
360
|
-
|
|
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
|
+
}
|
|
361
429
|
|
|
362
430
|
} catch (err) {
|
|
363
431
|
console.error('Share server error:', err.message);
|