@nightowne/tas-cli 2.1.0 → 2.4.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 +292 -92
- package/package.json +5 -3
- package/src/cli.js +181 -23
- package/src/crypto/encryption.js +38 -3
- package/src/db/index.js +19 -13
- package/src/fuse/mount.js +45 -47
- package/src/index.js +25 -70
- package/src/share/server.js +34 -56
- package/src/telegram/client.js +64 -16
- package/src/utils/branding.js +3 -3
- package/src/utils/chunker.js +4 -2
- package/src/utils/cli-helpers.js +44 -6
- package/src/utils/download-stream.js +87 -0
- package/src/utils/progress.js +3 -3
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, HEADER_SIZE } from './utils/chunker.js';
|
|
12
12
|
import { TelegramClient } from './telegram/client.js';
|
|
13
13
|
import { FileIndex } from './db/index.js';
|
|
14
14
|
|
|
@@ -173,11 +173,12 @@ export async function processFile(filePath, options) {
|
|
|
173
173
|
|
|
174
174
|
db.close();
|
|
175
175
|
|
|
176
|
-
// Clean up temp dir
|
|
176
|
+
// Clean up temp dir (only if empty)
|
|
177
177
|
try {
|
|
178
|
-
fs.
|
|
178
|
+
const remaining = fs.readdirSync(tempDir);
|
|
179
|
+
if (remaining.length === 0) fs.rmdirSync(tempDir);
|
|
179
180
|
} catch (e) {
|
|
180
|
-
// Ignore
|
|
181
|
+
// Ignore cleanup errors
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
return {
|
|
@@ -209,91 +210,45 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
209
210
|
throw new Error('No chunk metadata found for this file');
|
|
210
211
|
}
|
|
211
212
|
|
|
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
|
-
|
|
221
213
|
// Connect to Telegram
|
|
222
214
|
const client = new TelegramClient(dataDir);
|
|
223
215
|
await client.initialize(config.botToken);
|
|
224
216
|
client.setChatId(config.chatId);
|
|
225
217
|
|
|
226
|
-
|
|
227
|
-
const firstChunkData = await client.downloadFile(chunks[0].file_telegram_id);
|
|
228
|
-
const header = parseHeader(firstChunkData);
|
|
229
|
-
|
|
230
|
-
// Total original uncompressed size
|
|
231
|
-
let expectedOriginalSize = header.originalSize;
|
|
232
|
-
let wasCompressed = header.compressed;
|
|
233
|
-
|
|
218
|
+
const encryptor = new Encryptor(password);
|
|
234
219
|
const compressor = new Compressor();
|
|
235
|
-
const decompressStream = compressor.getDecompressStream(wasCompressed);
|
|
236
|
-
|
|
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');
|
|
240
|
-
|
|
241
|
-
const totalBytes = fileRecord.stored_size || chunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
242
|
-
let downloadedBytes = 0;
|
|
243
|
-
|
|
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
220
|
|
|
247
|
-
|
|
221
|
+
const { createDownloadPipeline } = await import('./utils/download-stream.js');
|
|
248
222
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
}
|
|
223
|
+
const { readable } = await createDownloadPipeline({
|
|
224
|
+
client,
|
|
225
|
+
chunks,
|
|
226
|
+
encryptor,
|
|
227
|
+
compressor,
|
|
228
|
+
onChunkDownloaded({ chunkIndex, totalChunks, bytesDownloaded, totalBytes }) {
|
|
229
|
+
onProgress?.(`Downloading chunk ${chunkIndex + 1}/${totalChunks}...`);
|
|
230
|
+
onByteProgress?.({ downloaded: bytesDownloaded, total: totalBytes, chunk: chunkIndex + 1, totalChunks });
|
|
282
231
|
}
|
|
283
232
|
});
|
|
284
233
|
|
|
285
234
|
const writeStream = fs.createWriteStream(outputPath);
|
|
286
|
-
const { pipeline } = await import('stream/promises');
|
|
287
235
|
|
|
288
236
|
onProgress?.('Decrypting, decompressing, and writing file...');
|
|
289
237
|
|
|
290
|
-
|
|
291
|
-
await pipeline(downloadStream, decryptStream, decompressStream, writeStream);
|
|
238
|
+
await pipeline(readable, writeStream);
|
|
292
239
|
|
|
293
240
|
const finalStats = fs.statSync(outputPath);
|
|
294
241
|
|
|
242
|
+
// Verify file integrity by comparing hash
|
|
243
|
+
onProgress?.('Verifying file integrity...');
|
|
244
|
+
const downloadedHash = await hashFile(outputPath);
|
|
245
|
+
if (fileRecord.hash && downloadedHash !== fileRecord.hash) {
|
|
246
|
+
throw new Error(`Integrity check failed: expected hash ${fileRecord.hash.substring(0, 12)}..., got ${downloadedHash.substring(0, 12)}...`);
|
|
247
|
+
}
|
|
248
|
+
|
|
295
249
|
return {
|
|
296
250
|
path: outputPath,
|
|
297
|
-
size: finalStats.size
|
|
251
|
+
size: finalStats.size,
|
|
252
|
+
verified: true
|
|
298
253
|
};
|
|
299
254
|
}
|
package/src/share/server.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
import http from 'http';
|
|
8
8
|
import crypto from 'crypto';
|
|
9
9
|
import path from 'path';
|
|
10
|
+
import { pipeline } from 'stream/promises';
|
|
10
11
|
import { FileIndex } from '../db/index.js';
|
|
11
12
|
import { TelegramClient } from '../telegram/client.js';
|
|
12
13
|
import { Encryptor } from '../crypto/encryption.js';
|
|
13
14
|
import { Compressor } from '../utils/compression.js';
|
|
14
|
-
import {
|
|
15
|
+
import { createDownloadPipeline } from '../utils/download-stream.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Generate a secure random share token
|
|
@@ -57,6 +58,25 @@ function formatTimeLeft(expiresAt) {
|
|
|
57
58
|
return `${minutes}m`;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Escape HTML special characters to prevent XSS
|
|
63
|
+
*/
|
|
64
|
+
function escapeHtml(str) {
|
|
65
|
+
return String(str)
|
|
66
|
+
.replace(/&/g, '&')
|
|
67
|
+
.replace(/</g, '<')
|
|
68
|
+
.replace(/>/g, '>')
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, ''');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Sanitize filename for Content-Disposition header (RFC 6266)
|
|
75
|
+
*/
|
|
76
|
+
function sanitizeFilenameForHeader(filename) {
|
|
77
|
+
return filename.replace(/["\\\r\n]/g, '_');
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
/**
|
|
61
81
|
* Generate the download HTML page
|
|
62
82
|
*/
|
|
@@ -69,6 +89,7 @@ function generateDownloadPage(share, fileRecord) {
|
|
|
69
89
|
: fileSize > 1024
|
|
70
90
|
? `${(fileSize / 1024).toFixed(1)} KB`
|
|
71
91
|
: `${fileSize} B`;
|
|
92
|
+
const safeFilename = escapeHtml(fileRecord.filename);
|
|
72
93
|
|
|
73
94
|
return `<!DOCTYPE html>
|
|
74
95
|
<html lang="en">
|
|
@@ -146,7 +167,7 @@ function generateDownloadPage(share, fileRecord) {
|
|
|
146
167
|
<div class="card">
|
|
147
168
|
<div class="icon">🔐</div>
|
|
148
169
|
<h1>Secure File Share</h1>
|
|
149
|
-
<div class="filename">${
|
|
170
|
+
<div class="filename">${safeFilename}</div>
|
|
150
171
|
<div class="meta">
|
|
151
172
|
<span>📦 ${sizeStr}</span>
|
|
152
173
|
<span>⏳ ${timeLeft}</span>
|
|
@@ -219,7 +240,7 @@ export class ShareServer {
|
|
|
219
240
|
this.password = options.password;
|
|
220
241
|
this.config = options.config;
|
|
221
242
|
this.port = options.port || 3000;
|
|
222
|
-
this.host = options.host || '
|
|
243
|
+
this.host = options.host || '127.0.0.1';
|
|
223
244
|
|
|
224
245
|
this.db = null;
|
|
225
246
|
this.client = null;
|
|
@@ -245,59 +266,15 @@ export class ShareServer {
|
|
|
245
266
|
*/
|
|
246
267
|
async streamToResponse(fileRecord, res) {
|
|
247
268
|
const chunks = this.db.getChunks(fileRecord.id);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const firstChunkData = await this.client.downloadFile(chunks[0].file_telegram_id);
|
|
255
|
-
const header = parseHeader(firstChunkData);
|
|
256
|
-
let wasCompressed = header.compressed;
|
|
257
|
-
|
|
258
|
-
// Prepare streams
|
|
259
|
-
const decryptStream = this.encryptor.getDecryptStream();
|
|
260
|
-
const decompressStream = this.compressor.getDecompressStream(wasCompressed);
|
|
261
|
-
|
|
262
|
-
// We need a Readable stream that will lazily fetch chunks from Telegram
|
|
263
|
-
const { Readable } = await import('stream');
|
|
264
|
-
const { pipeline } = await import('stream/promises');
|
|
265
|
-
|
|
266
|
-
const self = this;
|
|
267
|
-
let currentChunkIndex = 0;
|
|
268
|
-
let preloadedFirstChunk = firstChunkData;
|
|
269
|
-
|
|
270
|
-
const downloadStream = new Readable({
|
|
271
|
-
async read() {
|
|
272
|
-
try {
|
|
273
|
-
if (currentChunkIndex >= chunks.length) {
|
|
274
|
-
this.push(null); // End of stream
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const chunk = chunks[currentChunkIndex];
|
|
279
|
-
let data;
|
|
280
|
-
|
|
281
|
-
if (currentChunkIndex === 0 && preloadedFirstChunk) {
|
|
282
|
-
data = preloadedFirstChunk;
|
|
283
|
-
preloadedFirstChunk = null;
|
|
284
|
-
} else {
|
|
285
|
-
data = await self.client.downloadFile(chunk.file_telegram_id);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Strip header before pushing
|
|
289
|
-
const payload = data.subarray(HEADER_SIZE);
|
|
290
|
-
this.push(payload);
|
|
291
|
-
|
|
292
|
-
currentChunkIndex++;
|
|
293
|
-
} catch (err) {
|
|
294
|
-
this.destroy(err);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
269
|
+
|
|
270
|
+
const { readable } = await createDownloadPipeline({
|
|
271
|
+
client: this.client,
|
|
272
|
+
chunks,
|
|
273
|
+
encryptor: this.encryptor,
|
|
274
|
+
compressor: this.compressor
|
|
297
275
|
});
|
|
298
276
|
|
|
299
|
-
|
|
300
|
-
await pipeline(downloadStream, decryptStream, decompressStream, res);
|
|
277
|
+
await pipeline(readable, res);
|
|
301
278
|
}
|
|
302
279
|
|
|
303
280
|
/**
|
|
@@ -378,11 +355,12 @@ export class ShareServer {
|
|
|
378
355
|
'.mp3': 'audio/mpeg'
|
|
379
356
|
};
|
|
380
357
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
358
|
+
const safeName = sanitizeFilenameForHeader(fileRecord.filename);
|
|
381
359
|
|
|
382
360
|
res.writeHead(200, {
|
|
383
361
|
'Content-Type': contentType,
|
|
384
|
-
'Content-Disposition': `attachment; filename="${fileRecord.filename}
|
|
385
|
-
'
|
|
362
|
+
'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
|
|
363
|
+
'Transfer-Encoding': 'chunked'
|
|
386
364
|
});
|
|
387
365
|
|
|
388
366
|
// Download the file from Telegram, decrypt, decompress and stream directly to 'res'
|
package/src/telegram/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram Bot client wrapper
|
|
3
3
|
* Uses official Telegram Bot API - 2GB file limit, FREE, no ban risk!
|
|
4
|
+
* Includes exponential backoff retry and rate limiting for production reliability.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import TelegramBot from 'node-telegram-bot-api';
|
|
@@ -8,11 +9,46 @@ import fs from 'fs';
|
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import { pipeline } from 'stream/promises';
|
|
10
11
|
|
|
12
|
+
const MAX_RETRIES = 5;
|
|
13
|
+
const BASE_DELAY_MS = 1000;
|
|
14
|
+
const MAX_DELAY_MS = 60000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Retry an async function with exponential backoff + jitter.
|
|
18
|
+
* Handles Telegram 429 (rate limit) errors by respecting retry_after.
|
|
19
|
+
*/
|
|
20
|
+
async function withRetry(fn, label = 'operation', retries = MAX_RETRIES) {
|
|
21
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const isRateLimit = err?.response?.statusCode === 429 || err?.message?.includes('429');
|
|
26
|
+
const isTransient = err?.code === 'ETIMEOUT' || err?.code === 'ECONNRESET' ||
|
|
27
|
+
err?.code === 'ENOTFOUND' || err?.message?.includes('ETIMEDOUT');
|
|
28
|
+
|
|
29
|
+
if (attempt >= retries) throw err;
|
|
30
|
+
if (!isRateLimit && !isTransient) throw err;
|
|
31
|
+
|
|
32
|
+
let delay;
|
|
33
|
+
if (isRateLimit && err?.response?.body?.parameters?.retry_after) {
|
|
34
|
+
delay = err.response.body.parameters.retry_after * 1000 + 500;
|
|
35
|
+
} else {
|
|
36
|
+
delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * 1000, MAX_DELAY_MS);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sec = (delay / 1000).toFixed(1);
|
|
40
|
+
console.log(`⏳ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${sec}s...`);
|
|
41
|
+
await new Promise(r => setTimeout(r, delay));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
11
46
|
export class TelegramClient {
|
|
12
47
|
constructor(dataDir) {
|
|
13
48
|
this.dataDir = dataDir;
|
|
14
49
|
this.bot = null;
|
|
15
50
|
this.chatId = null;
|
|
51
|
+
this._lastSendTime = 0;
|
|
16
52
|
}
|
|
17
53
|
|
|
18
54
|
/**
|
|
@@ -76,9 +112,22 @@ export class TelegramClient {
|
|
|
76
112
|
});
|
|
77
113
|
}
|
|
78
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Rate-limit: ensure at least 1s between sends to same chat (Telegram limit)
|
|
117
|
+
*/
|
|
118
|
+
async _rateLimit() {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const elapsed = now - this._lastSendTime;
|
|
121
|
+
if (elapsed < 1000) {
|
|
122
|
+
await new Promise(r => setTimeout(r, 1000 - elapsed));
|
|
123
|
+
}
|
|
124
|
+
this._lastSendTime = Date.now();
|
|
125
|
+
}
|
|
126
|
+
|
|
79
127
|
/**
|
|
80
128
|
* Send a file to the storage chat
|
|
81
129
|
* Telegram supports up to 2GB for documents!
|
|
130
|
+
* Includes automatic retry with exponential backoff.
|
|
82
131
|
*/
|
|
83
132
|
async sendFile(filePath, caption = '', options = {}) {
|
|
84
133
|
if (!this.chatId) {
|
|
@@ -89,9 +138,12 @@ export class TelegramClient {
|
|
|
89
138
|
throw new Error(`File not found: ${filePath}`);
|
|
90
139
|
}
|
|
91
140
|
|
|
92
|
-
|
|
141
|
+
const filename = path.basename(filePath);
|
|
142
|
+
|
|
143
|
+
return withRetry(async () => {
|
|
144
|
+
await this._rateLimit();
|
|
145
|
+
|
|
93
146
|
let fileStream = fs.createReadStream(filePath);
|
|
94
|
-
const filename = path.basename(filePath);
|
|
95
147
|
|
|
96
148
|
if (options.limitRate) {
|
|
97
149
|
const { Throttle } = await import('../utils/throttle.js');
|
|
@@ -110,28 +162,24 @@ export class TelegramClient {
|
|
|
110
162
|
fileId: message.document.file_id,
|
|
111
163
|
timestamp: message.date
|
|
112
164
|
};
|
|
113
|
-
}
|
|
114
|
-
throw new Error(`Failed to upload to Telegram: ${err.message}`);
|
|
115
|
-
}
|
|
165
|
+
}, `Upload ${filename}`);
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
/**
|
|
119
169
|
* Download a file from Telegram (In-Memory buffer)
|
|
170
|
+
* Includes automatic retry with exponential backoff.
|
|
120
171
|
*/
|
|
121
172
|
async downloadFile(fileId) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// Download the file
|
|
126
|
-
const fileStream = await this.bot.getFileStream(fileId);
|
|
173
|
+
return withRetry(async () => {
|
|
174
|
+
const fileStream = await this.bot.getFileStream(fileId);
|
|
127
175
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
176
|
+
const chunks = [];
|
|
177
|
+
for await (const chunk of fileStream) {
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
}
|
|
133
180
|
|
|
134
|
-
|
|
181
|
+
return Buffer.concat(chunks);
|
|
182
|
+
}, `Download ${fileId.substring(0, 12)}...`);
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
/**
|
package/src/utils/branding.js
CHANGED
|
@@ -14,7 +14,7 @@ export const LOGO = `
|
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
16
|
export const TAGLINE = 'Telegram as Storage';
|
|
17
|
-
export const VERSION = '
|
|
17
|
+
export const VERSION = '2.4.0';
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Print the TAS banner
|
|
@@ -57,10 +57,10 @@ export function warn(msg) {
|
|
|
57
57
|
* Format file size
|
|
58
58
|
*/
|
|
59
59
|
export function formatSize(bytes) {
|
|
60
|
-
if (bytes
|
|
60
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
61
61
|
const k = 1024;
|
|
62
62
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
63
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
63
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
64
64
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
65
65
|
}
|
|
66
66
|
|
package/src/utils/chunker.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File chunking utilities for large files
|
|
3
|
-
*
|
|
3
|
+
* Telegram Bot API limit is 50 MB for bot uploads (sendDocument).
|
|
4
|
+
* We use 49 MB to leave room for the 64-byte WAS1 header.
|
|
5
|
+
* See: https://core.telegram.org/bots/api#senddocument
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
|
-
const MAX_CHUNK_SIZE =
|
|
8
|
+
const MAX_CHUNK_SIZE = 49 * 1024 * 1024; // 49 MB — Telegram Bot API safe limit
|
|
7
9
|
|
|
8
10
|
export class Chunker {
|
|
9
11
|
/**
|
package/src/utils/cli-helpers.js
CHANGED
|
@@ -39,14 +39,13 @@ export async function getPassword(passwordOption, allowCache = true) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Verify password against config
|
|
42
|
+
* Verify password against config (supports both legacy and new hash formats)
|
|
43
43
|
* @param {string} password - Password to verify
|
|
44
44
|
* @param {Object} config - Config object with passwordHash
|
|
45
45
|
* @returns {boolean}
|
|
46
46
|
*/
|
|
47
47
|
export function verifyPassword(password, config) {
|
|
48
|
-
|
|
49
|
-
return encryptor.getPasswordHash() === config.passwordHash;
|
|
48
|
+
return Encryptor.verifyPasswordHash(password, config.passwordHash);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
/**
|
|
@@ -62,11 +61,13 @@ export function validateConfig(config) {
|
|
|
62
61
|
return { valid: false, errors };
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
// v2: encrypted token, v1: plaintext token
|
|
65
|
+
const hasToken = config.encryptedBotToken || config.botToken;
|
|
66
|
+
if (!hasToken) {
|
|
67
|
+
errors.push('Missing bot token (botToken or encryptedBotToken)');
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
if (!config.botToken
|
|
70
|
+
if (config.botToken && !config.botToken.includes(':')) {
|
|
70
71
|
errors.push('Invalid bot token format (should contain :)');
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -84,6 +85,29 @@ export function validateConfig(config) {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Decrypt bot token from config using password
|
|
90
|
+
* Supports both v1 (plaintext) and v2 (encrypted) configs
|
|
91
|
+
* @param {Object} config - Config object
|
|
92
|
+
* @param {string} password - User's password
|
|
93
|
+
* @returns {string} - Decrypted bot token
|
|
94
|
+
*/
|
|
95
|
+
export function decryptBotToken(config, password) {
|
|
96
|
+
// v1: plaintext token (backward compatibility)
|
|
97
|
+
if (config.botToken) {
|
|
98
|
+
return config.botToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// v2: encrypted token
|
|
102
|
+
if (config.encryptedBotToken) {
|
|
103
|
+
const encryptor = new Encryptor(password);
|
|
104
|
+
const encryptedBuffer = Buffer.from(config.encryptedBotToken, 'base64');
|
|
105
|
+
return encryptor.decrypt(encryptedBuffer).toString('utf-8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw new Error('No bot token found in config');
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
/**
|
|
88
112
|
* Load and validate config
|
|
89
113
|
* @param {string} dataDir - Data directory path
|
|
@@ -143,3 +167,17 @@ export async function getAndVerifyPassword(passwordOption, dataDir) {
|
|
|
143
167
|
|
|
144
168
|
return password;
|
|
145
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve config by decrypting bot token if needed.
|
|
173
|
+
* Returns a config object with a guaranteed plaintext `botToken` field.
|
|
174
|
+
* @param {Object} config - Raw config from disk
|
|
175
|
+
* @param {string} password - Verified password
|
|
176
|
+
* @returns {Object} - Config with decrypted botToken
|
|
177
|
+
*/
|
|
178
|
+
export function resolveConfig(config, password) {
|
|
179
|
+
return {
|
|
180
|
+
...config,
|
|
181
|
+
botToken: decryptBotToken(config, password)
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared download pipeline — Telegram → Decrypt → Decompress
|
|
3
|
+
*
|
|
4
|
+
* Eliminates the triplicated download-stream pattern across
|
|
5
|
+
* index.js, share/server.js, and fuse/mount.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Readable } from 'stream';
|
|
9
|
+
import { pipeline } from 'stream/promises';
|
|
10
|
+
import { parseHeader, HEADER_SIZE } from './chunker.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create the download pipeline streams for a file stored in Telegram.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* @param {object} options.client – TelegramClient instance (initialized, with chatId set)
|
|
17
|
+
* @param {Array} options.chunks – Chunk records from DB, each with { chunk_index, file_telegram_id, size }
|
|
18
|
+
* @param {object} options.encryptor – Encryptor instance
|
|
19
|
+
* @param {object} options.compressor – Compressor instance
|
|
20
|
+
* @param {function} [options.onChunkDownloaded] – Optional callback({ chunkIndex, totalChunks, bytesDownloaded, totalBytes })
|
|
21
|
+
* @returns {Promise<{ readable: Readable, header: object }>}
|
|
22
|
+
* readable: a stream of decrypted (and decompressed) file content
|
|
23
|
+
* header: parsed WAS1 header from the first chunk
|
|
24
|
+
*/
|
|
25
|
+
export async function createDownloadPipeline({ client, chunks, encryptor, compressor, onChunkDownloaded }) {
|
|
26
|
+
if (chunks.length === 0) throw new Error('No chunks found');
|
|
27
|
+
|
|
28
|
+
// Sort by chunk_index
|
|
29
|
+
const sortedChunks = [...chunks].sort((a, b) => a.chunk_index - b.chunk_index);
|
|
30
|
+
|
|
31
|
+
// Download the first chunk to inspect the header
|
|
32
|
+
const firstChunkData = await client.downloadFile(sortedChunks[0].file_telegram_id);
|
|
33
|
+
const header = parseHeader(firstChunkData);
|
|
34
|
+
|
|
35
|
+
const decryptStream = encryptor.getDecryptStream();
|
|
36
|
+
const decompressStream = compressor.getDecompressStream(header.compressed);
|
|
37
|
+
|
|
38
|
+
const totalBytes = sortedChunks.reduce((acc, c) => acc + (c.size || 0), 0);
|
|
39
|
+
let bytesDownloaded = 0;
|
|
40
|
+
let currentIndex = 0;
|
|
41
|
+
let preloadedFirst = firstChunkData;
|
|
42
|
+
|
|
43
|
+
const telegramStream = new Readable({
|
|
44
|
+
async read() {
|
|
45
|
+
try {
|
|
46
|
+
if (currentIndex >= sortedChunks.length) {
|
|
47
|
+
this.push(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let data;
|
|
52
|
+
if (currentIndex === 0 && preloadedFirst) {
|
|
53
|
+
data = preloadedFirst;
|
|
54
|
+
preloadedFirst = null;
|
|
55
|
+
} else {
|
|
56
|
+
data = await client.downloadFile(sortedChunks[currentIndex].file_telegram_id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
bytesDownloaded += data.length;
|
|
60
|
+
onChunkDownloaded?.({
|
|
61
|
+
chunkIndex: currentIndex,
|
|
62
|
+
totalChunks: sortedChunks.length,
|
|
63
|
+
bytesDownloaded,
|
|
64
|
+
totalBytes
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Strip the WAS1 header before pushing into the decrypt pipeline
|
|
68
|
+
this.push(data.subarray(HEADER_SIZE));
|
|
69
|
+
currentIndex++;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this.destroy(err);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Wire the internal pipeline: telegram → decrypt → decompress
|
|
77
|
+
// We use a PassThrough as the readable end so callers can pipe/pipeline it freely.
|
|
78
|
+
const { PassThrough } = await import('stream');
|
|
79
|
+
const output = new PassThrough();
|
|
80
|
+
|
|
81
|
+
// Run the internal pipeline in the background; errors propagate through the output stream.
|
|
82
|
+
pipeline(telegramStream, decryptStream, decompressStream, output).catch((err) => {
|
|
83
|
+
if (!output.destroyed) output.destroy(err);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { readable: output, header };
|
|
87
|
+
}
|
package/src/utils/progress.js
CHANGED
|
@@ -76,10 +76,10 @@ export class ProgressBar {
|
|
|
76
76
|
* Format bytes to human readable
|
|
77
77
|
*/
|
|
78
78
|
formatBytes(bytes) {
|
|
79
|
-
if (bytes
|
|
79
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
80
80
|
const k = 1024;
|
|
81
|
-
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
82
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
81
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
82
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
|
83
83
|
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
|
|
84
84
|
}
|
|
85
85
|
|