@nightowne/tas-cli 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/FAQ.md +33 -0
- package/QUICKSTART.md +47 -0
- package/README.md +356 -196
- package/package.json +11 -5
- package/src/cli.js +758 -213
- package/src/db/index.js +307 -24
- package/src/fuse/mount.js +336 -175
- package/src/index.js +187 -123
- package/src/manifest.js +104 -0
- package/src/share/server.js +7 -9
- package/src/sync/sync.js +105 -34
- package/src/telegram/client.js +10 -3
- package/src/telegram/pool.js +105 -0
- package/src/utils/branding.js +2 -2
- package/src/utils/chunker.js +4 -3
- package/src/utils/cli-helpers.js +90 -10
- package/src/utils/download-stream.js +10 -4
- package/src/utils/logical-path.js +44 -0
package/src/index.js
CHANGED
|
@@ -8,24 +8,45 @@ 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 { createHeader, HEADER_SIZE } from './utils/chunker.js';
|
|
12
|
-
import {
|
|
11
|
+
import { createHeader, HEADER_SIZE, MAX_CHUNK_SIZE } from './utils/chunker.js';
|
|
12
|
+
import { TelegramPool } from './telegram/pool.js';
|
|
13
13
|
import { FileIndex } from './db/index.js';
|
|
14
|
+
import { normalizeLogicalPath } from './utils/logical-path.js';
|
|
15
|
+
import { backupRemoteManifest } from './manifest.js';
|
|
14
16
|
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
const TELEGRAM_CHUNK_SIZE =
|
|
17
|
+
// The hosted Bot API can upload 50 MB but getFile downloads only up to 20 MB.
|
|
18
|
+
// A 19 MiB encrypted payload plus the 64-byte TAS header is round-trip safe.
|
|
19
|
+
export const TELEGRAM_CHUNK_SIZE = MAX_CHUNK_SIZE;
|
|
20
|
+
|
|
21
|
+
export function createPublicChunkHeader(chunkIndex, totalChunks, flags) {
|
|
22
|
+
return createHeader('', 0, chunkIndex, totalChunks, flags);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createChunkCaption(uploadId, chunkIndex, totalChunks) {
|
|
26
|
+
return `tas:c1:${uploadId}:${chunkIndex + 1}/${totalChunks}`;
|
|
27
|
+
}
|
|
18
28
|
|
|
19
29
|
/**
|
|
20
30
|
* Process and upload a file to Telegram
|
|
21
31
|
*/
|
|
22
32
|
export async function processFile(filePath, options) {
|
|
23
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
password,
|
|
35
|
+
dataDir,
|
|
36
|
+
customName,
|
|
37
|
+
config,
|
|
38
|
+
onProgress,
|
|
39
|
+
onByteProgress,
|
|
40
|
+
limitRate,
|
|
41
|
+
telegramPool,
|
|
42
|
+
replaceExisting = false,
|
|
43
|
+
updateManifest = true
|
|
44
|
+
} = options;
|
|
24
45
|
|
|
25
46
|
onProgress?.('Reading file...');
|
|
26
47
|
|
|
27
48
|
// Read file initially just to get size
|
|
28
|
-
const filename = customName || path.basename(filePath);
|
|
49
|
+
const filename = normalizeLogicalPath(customName || path.basename(filePath));
|
|
29
50
|
const stats = fs.statSync(filePath);
|
|
30
51
|
const originalSize = stats.size;
|
|
31
52
|
|
|
@@ -33,14 +54,21 @@ export async function processFile(filePath, options) {
|
|
|
33
54
|
onProgress?.('Calculating hash...');
|
|
34
55
|
const hash = await hashFile(filePath);
|
|
35
56
|
|
|
36
|
-
//
|
|
57
|
+
// Logical paths are exact. Identical bytes may legitimately exist under
|
|
58
|
+
// different paths, so content hash is indexed but no longer globally unique.
|
|
37
59
|
const db = new FileIndex(path.join(dataDir, 'index.db'));
|
|
38
60
|
db.init();
|
|
39
61
|
|
|
40
|
-
|
|
62
|
+
const existingFile = db.findByExactName(filename);
|
|
63
|
+
if (existingFile && existingFile.hash === hash) {
|
|
41
64
|
db.close();
|
|
42
|
-
throw new Error('
|
|
65
|
+
throw new Error('This logical path already contains the same file');
|
|
43
66
|
}
|
|
67
|
+
if (existingFile && !replaceExisting) {
|
|
68
|
+
db.close();
|
|
69
|
+
throw new Error(`A different file already exists at "${filename}"`);
|
|
70
|
+
}
|
|
71
|
+
const existingChunks = existingFile ? db.getChunks(existingFile.id) : [];
|
|
44
72
|
|
|
45
73
|
// Prepare processing components
|
|
46
74
|
const compressor = new Compressor();
|
|
@@ -50,144 +78,182 @@ export async function processFile(filePath, options) {
|
|
|
50
78
|
const encryptor = new Encryptor(password);
|
|
51
79
|
const encryptStream = encryptor.getEncryptStream();
|
|
52
80
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
81
|
+
const tempRoot = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
|
|
82
|
+
fs.mkdirSync(tempRoot, { recursive: true });
|
|
83
|
+
const uploadDir = fs.mkdtempSync(path.join(tempRoot, `${hash.substring(0, 12)}-`));
|
|
57
84
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
await client.initialize(config.botToken);
|
|
62
|
-
client.setChatId(config.chatId);
|
|
63
|
-
|
|
64
|
-
// We will stream through a custom Writable chunker
|
|
85
|
+
// First stream the exact encrypted chunks to disk. This makes upload
|
|
86
|
+
// resumption real: once network transfer starts, every remaining chunk is
|
|
87
|
+
// already durable locally and every completed Telegram ID is in SQLite.
|
|
65
88
|
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
|
|
76
|
-
const fileId = db.addFile({
|
|
77
|
-
filename,
|
|
78
|
-
hash,
|
|
79
|
-
originalSize,
|
|
80
|
-
storedSize: 0, // Will update later
|
|
81
|
-
chunks: estimatedChunks,
|
|
82
|
-
compressed
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
onProgress?.('Processing and uploading streams...');
|
|
86
|
-
let uploadedBytes = 0;
|
|
87
|
-
let chunkIndex = 0;
|
|
88
|
-
|
|
89
89
|
let currentChunkBuffer = Buffer.alloc(0);
|
|
90
90
|
let totalStoredSize = 0;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
|
|
101
|
-
const chunkData = Buffer.concat([header, currentChunkBuffer]);
|
|
102
|
-
|
|
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);
|
|
109
|
-
|
|
110
|
-
const caption = totalChunks > 1
|
|
111
|
-
? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
|
|
112
|
-
: `📦 ${filename}`;
|
|
113
|
-
|
|
114
|
-
onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
|
|
115
|
-
|
|
116
|
-
const result = await client.sendFile(chunkPath, caption);
|
|
117
|
-
|
|
118
|
-
uploadedBytes += chunkData.length;
|
|
119
|
-
totalStoredSize += currentChunkBuffer.length;
|
|
120
|
-
|
|
121
|
-
onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
|
|
122
|
-
|
|
123
|
-
// Store file_id
|
|
124
|
-
db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
|
|
125
|
-
db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
126
|
-
.run(result.fileId, fileId, chunkIndex);
|
|
127
|
-
|
|
128
|
-
// Clean up temp file immediately to save disk space
|
|
129
|
-
fs.unlinkSync(chunkPath);
|
|
130
|
-
|
|
131
|
-
chunkIndex++;
|
|
132
|
-
currentChunkBuffer = Buffer.alloc(0);
|
|
91
|
+
const stagedChunks = [];
|
|
92
|
+
|
|
93
|
+
const stageChunk = (payload) => {
|
|
94
|
+
const index = stagedChunks.length;
|
|
95
|
+
const chunkPath = path.join(uploadDir, `chunk-${String(index).padStart(6, '0')}.tas`);
|
|
96
|
+
const header = createPublicChunkHeader(index, 0, flags);
|
|
97
|
+
fs.writeFileSync(chunkPath, Buffer.concat([header, payload]), { mode: 0o600 });
|
|
98
|
+
totalStoredSize += payload.length;
|
|
99
|
+
stagedChunks.push({ index, path: chunkPath, size: header.length + payload.length });
|
|
133
100
|
};
|
|
134
101
|
|
|
135
102
|
const chunkingStream = new Writable({
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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);
|
|
103
|
+
write(chunk, encoding, callback) {
|
|
104
|
+
try {
|
|
105
|
+
currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
|
|
106
|
+
while (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
|
|
107
|
+
stageChunk(currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE));
|
|
108
|
+
currentChunkBuffer = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
|
|
150
109
|
}
|
|
151
|
-
} else {
|
|
152
110
|
callback();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
callback(error);
|
|
153
113
|
}
|
|
154
114
|
},
|
|
155
|
-
|
|
115
|
+
final(callback) {
|
|
156
116
|
try {
|
|
157
|
-
|
|
117
|
+
if (currentChunkBuffer.length > 0 || stagedChunks.length === 0) stageChunk(currentChunkBuffer);
|
|
158
118
|
callback();
|
|
159
|
-
} catch (
|
|
160
|
-
callback(
|
|
119
|
+
} catch (error) {
|
|
120
|
+
callback(error);
|
|
161
121
|
}
|
|
162
122
|
}
|
|
163
123
|
});
|
|
164
124
|
|
|
165
|
-
|
|
125
|
+
onProgress?.('Compressing and encrypting to resumable chunks...');
|
|
126
|
+
try {
|
|
127
|
+
await pipeline(fs.createReadStream(filePath), compressStream, encryptStream, chunkingStream);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
try { fs.rmSync(uploadDir, { recursive: true, force: true }); } catch { }
|
|
130
|
+
db.close();
|
|
131
|
+
throw new Error(`Local processing failed before upload: ${error.message}`);
|
|
132
|
+
}
|
|
166
133
|
|
|
167
|
-
|
|
168
|
-
|
|
134
|
+
const totalChunks = stagedChunks.length;
|
|
135
|
+
if (totalChunks > 0xffff) {
|
|
136
|
+
try { fs.rmSync(uploadDir, { recursive: true, force: true }); } catch { }
|
|
137
|
+
db.close();
|
|
138
|
+
throw new Error(`File requires ${totalChunks} chunks, above the WAS1 limit of 65,535`);
|
|
139
|
+
}
|
|
140
|
+
for (const staged of stagedChunks) {
|
|
141
|
+
const fd = fs.openSync(staged.path, 'r+');
|
|
142
|
+
try {
|
|
143
|
+
fs.writeSync(fd, createPublicChunkHeader(staged.index, totalChunks, flags), 0, HEADER_SIZE, 0);
|
|
144
|
+
} finally {
|
|
145
|
+
fs.closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
169
148
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
.
|
|
149
|
+
let pendingId;
|
|
150
|
+
try {
|
|
151
|
+
pendingId = db.addPendingUpload({
|
|
152
|
+
filename,
|
|
153
|
+
filePath,
|
|
154
|
+
hash,
|
|
155
|
+
originalSize,
|
|
156
|
+
storedSize: totalStoredSize,
|
|
157
|
+
compressed,
|
|
158
|
+
totalChunks,
|
|
159
|
+
uploadedChunks: 0,
|
|
160
|
+
tempDir: uploadDir
|
|
161
|
+
});
|
|
162
|
+
for (const staged of stagedChunks) db.addPendingChunk(pendingId, staged.index, staged.path, staged.size);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
try { fs.rmSync(uploadDir, { recursive: true, force: true }); } catch { }
|
|
165
|
+
db.close();
|
|
166
|
+
throw new Error(`Could not persist resumable upload state: ${error.message}`);
|
|
167
|
+
}
|
|
173
168
|
|
|
174
|
-
|
|
169
|
+
onProgress?.('Connecting to Telegram...');
|
|
170
|
+
const client = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
171
|
+
if (!telegramPool) await client.initialize({ includeDisabled: false });
|
|
175
172
|
|
|
176
|
-
|
|
173
|
+
let uploadedBytes = 0;
|
|
177
174
|
try {
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
175
|
+
for (const staged of stagedChunks) {
|
|
176
|
+
onProgress?.(`Uploading chunk ${staged.index + 1}/${totalChunks}...`);
|
|
177
|
+
const botId = client.selectBotId(hash, staged.index);
|
|
178
|
+
const result = await client.sendFile(
|
|
179
|
+
staged.path,
|
|
180
|
+
createChunkCaption(pendingId, staged.index, totalChunks),
|
|
181
|
+
{
|
|
182
|
+
...(limitRate ? { limitRate } : {}),
|
|
183
|
+
botId,
|
|
184
|
+
routingKey: hash,
|
|
185
|
+
chunkIndex: staged.index
|
|
186
|
+
}
|
|
187
|
+
);
|
|
188
|
+
db.markChunkUploaded(
|
|
189
|
+
pendingId,
|
|
190
|
+
staged.index,
|
|
191
|
+
String(result.messageId),
|
|
192
|
+
result.fileId,
|
|
193
|
+
result.botId
|
|
194
|
+
);
|
|
195
|
+
uploadedBytes += staged.size;
|
|
196
|
+
onByteProgress?.({
|
|
197
|
+
uploaded: uploadedBytes,
|
|
198
|
+
total: stagedChunks.reduce((sum, chunk) => sum + chunk.size, 0),
|
|
199
|
+
chunk: staged.index + 1,
|
|
200
|
+
totalChunks
|
|
201
|
+
});
|
|
202
|
+
fs.unlinkSync(staged.path);
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
db.close();
|
|
206
|
+
throw new Error(`${error.message} (upload paused — run \`tas resume\` to continue)`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const uploadedChunks = db.getPendingChunks(pendingId);
|
|
210
|
+
let fileId;
|
|
211
|
+
db.db.transaction(() => {
|
|
212
|
+
fileId = db.addFile({ filename, hash, originalSize, storedSize: totalStoredSize, chunks: totalChunks, compressed });
|
|
213
|
+
for (const chunk of uploadedChunks) {
|
|
214
|
+
db.addChunk(
|
|
215
|
+
fileId,
|
|
216
|
+
chunk.chunk_index,
|
|
217
|
+
chunk.message_id,
|
|
218
|
+
chunk.size,
|
|
219
|
+
chunk.file_telegram_id,
|
|
220
|
+
chunk.bot_id || null
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (existingFile) db.deleteFileCascade(existingFile.id);
|
|
224
|
+
db.deletePendingUpload(pendingId);
|
|
225
|
+
})();
|
|
226
|
+
|
|
227
|
+
db.close();
|
|
228
|
+
|
|
229
|
+
let manifestWarning = null;
|
|
230
|
+
if (updateManifest) {
|
|
231
|
+
onProgress?.('Publishing encrypted recovery manifest...');
|
|
232
|
+
try {
|
|
233
|
+
await backupRemoteManifest({ dataDir, password, config, telegramPool: client });
|
|
234
|
+
for (const chunk of existingChunks) {
|
|
235
|
+
try { await client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch { }
|
|
236
|
+
}
|
|
237
|
+
} catch (error) {
|
|
238
|
+
manifestWarning = error.message;
|
|
239
|
+
}
|
|
182
240
|
}
|
|
183
241
|
|
|
242
|
+
// Clean up this upload's private staging directory.
|
|
243
|
+
try {
|
|
244
|
+
fs.rmdirSync(uploadDir);
|
|
245
|
+
if (fs.readdirSync(tempRoot).length === 0) fs.rmdirSync(tempRoot);
|
|
246
|
+
} catch { }
|
|
247
|
+
|
|
184
248
|
return {
|
|
185
249
|
filename,
|
|
186
250
|
hash,
|
|
187
251
|
originalSize,
|
|
188
252
|
storedSize: totalStoredSize,
|
|
189
|
-
chunks:
|
|
190
|
-
compressed
|
|
253
|
+
chunks: totalChunks,
|
|
254
|
+
compressed,
|
|
255
|
+
manifestWarning,
|
|
256
|
+
supersededChunks: existingChunks
|
|
191
257
|
};
|
|
192
258
|
}
|
|
193
259
|
|
|
@@ -195,7 +261,7 @@ export async function processFile(filePath, options) {
|
|
|
195
261
|
* Retrieve a file from Telegram
|
|
196
262
|
*/
|
|
197
263
|
export async function retrieveFile(fileRecord, options) {
|
|
198
|
-
const { password, dataDir, outputPath, config, onProgress, onByteProgress } = options;
|
|
264
|
+
const { password, dataDir, outputPath, config, onProgress, onByteProgress, telegramPool } = options;
|
|
199
265
|
|
|
200
266
|
onProgress?.('Connecting to Telegram...');
|
|
201
267
|
|
|
@@ -211,9 +277,7 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
211
277
|
}
|
|
212
278
|
|
|
213
279
|
// Connect to Telegram
|
|
214
|
-
const client = new
|
|
215
|
-
await client.initialize(config.botToken);
|
|
216
|
-
client.setChatId(config.chatId);
|
|
280
|
+
const client = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
217
281
|
|
|
218
282
|
const encryptor = new Encryptor(password);
|
|
219
283
|
const compressor = new Compressor();
|
package/src/manifest.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted remote index manifest.
|
|
3
|
+
*
|
|
4
|
+
* Telegram file IDs are not discoverable from ciphertext alone. TAS therefore
|
|
5
|
+
* keeps a compact encrypted snapshot of files/chunks/tags and stores
|
|
6
|
+
* the latest manifest pointer in config.json. Losing index.db is recoverable as
|
|
7
|
+
* long as config.json, the password, and the manifest message still exist.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import zlib from 'zlib';
|
|
14
|
+
import { FileIndex } from './db/index.js';
|
|
15
|
+
import { Encryptor } from './crypto/encryption.js';
|
|
16
|
+
import { TelegramPool } from './telegram/pool.js';
|
|
17
|
+
import { loadConfig, saveConfig } from './utils/cli-helpers.js';
|
|
18
|
+
import { MAX_CHUNK_SIZE } from './utils/chunker.js';
|
|
19
|
+
|
|
20
|
+
const queues = new Map();
|
|
21
|
+
|
|
22
|
+
async function writeRemoteManifest({ dataDir, password, config, telegramPool }) {
|
|
23
|
+
const db = new FileIndex(path.join(dataDir, 'index.db'));
|
|
24
|
+
db.init();
|
|
25
|
+
const snapshot = db.exportManifest();
|
|
26
|
+
db.close();
|
|
27
|
+
|
|
28
|
+
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot)), { level: 9 });
|
|
29
|
+
const encrypted = new Encryptor(password).encrypt(compressed);
|
|
30
|
+
if (encrypted.length > MAX_CHUNK_SIZE) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Encrypted index manifest is ${encrypted.length} bytes, above the single-message recovery limit. ` +
|
|
33
|
+
'Refusing to publish an incomplete recovery point.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const pool = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
38
|
+
const routingKey = `manifest:${snapshot.createdAt}`;
|
|
39
|
+
const botId = pool.selectBotId(routingKey, 0);
|
|
40
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tas-manifest-'));
|
|
41
|
+
const manifestPath = path.join(tempDir, 'tas-index.m1');
|
|
42
|
+
fs.writeFileSync(manifestPath, encrypted, { mode: 0o600 });
|
|
43
|
+
|
|
44
|
+
let result;
|
|
45
|
+
try {
|
|
46
|
+
result = await pool.sendFile(manifestPath, 'tas:m1', { botId, routingKey, chunkIndex: 0 });
|
|
47
|
+
} finally {
|
|
48
|
+
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rawConfig = loadConfig(dataDir);
|
|
52
|
+
if (!rawConfig) throw new Error('Config disappeared while publishing remote manifest');
|
|
53
|
+
const previous = rawConfig.remoteManifest;
|
|
54
|
+
rawConfig.remoteManifest = {
|
|
55
|
+
version: 1,
|
|
56
|
+
botId: result.botId,
|
|
57
|
+
messageId: String(result.messageId),
|
|
58
|
+
fileId: result.fileId,
|
|
59
|
+
createdAt: snapshot.createdAt,
|
|
60
|
+
files: snapshot.files.length,
|
|
61
|
+
chunks: snapshot.chunks.length
|
|
62
|
+
};
|
|
63
|
+
saveConfig(dataDir, rawConfig);
|
|
64
|
+
|
|
65
|
+
// The new pointer is durable locally before the prior recovery point is
|
|
66
|
+
// removed. Failure to remove merely leaves an encrypted orphan manifest.
|
|
67
|
+
if (previous?.messageId) {
|
|
68
|
+
try { await pool.deleteMessage(previous.messageId, previous.botId || null); } catch { }
|
|
69
|
+
}
|
|
70
|
+
return rawConfig.remoteManifest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Serialize manifest writes inside one TAS process (notably sync workers). */
|
|
74
|
+
export function backupRemoteManifest(options) {
|
|
75
|
+
const key = path.resolve(options.dataDir);
|
|
76
|
+
const previous = queues.get(key) || Promise.resolve();
|
|
77
|
+
const next = previous.catch(() => { }).then(() => writeRemoteManifest(options));
|
|
78
|
+
queues.set(key, next);
|
|
79
|
+
return next.finally(() => {
|
|
80
|
+
if (queues.get(key) === next) queues.delete(key);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function downloadRemoteManifest({ dataDir, password, config, telegramPool }) {
|
|
85
|
+
const rawConfig = loadConfig(dataDir);
|
|
86
|
+
const pointer = rawConfig?.remoteManifest;
|
|
87
|
+
if (!pointer?.fileId) {
|
|
88
|
+
throw new Error('No remote manifest pointer exists in config.json');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pool = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
92
|
+
const encrypted = await pool.downloadFile(pointer.fileId, pointer.botId || null);
|
|
93
|
+
let manifest;
|
|
94
|
+
try {
|
|
95
|
+
const compressed = new Encryptor(password).decrypt(encrypted);
|
|
96
|
+
manifest = JSON.parse(zlib.gunzipSync(compressed).toString('utf8'));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(`Remote manifest authentication/decode failed: ${error.message}`);
|
|
99
|
+
}
|
|
100
|
+
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.files) || !Array.isArray(manifest.chunks)) {
|
|
101
|
+
throw new Error('Remote manifest is malformed or unsupported');
|
|
102
|
+
}
|
|
103
|
+
return manifest;
|
|
104
|
+
}
|
package/src/share/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import crypto from 'crypto';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { pipeline } from 'stream/promises';
|
|
11
11
|
import { FileIndex } from '../db/index.js';
|
|
12
|
-
import {
|
|
12
|
+
import { TelegramPool } from '../telegram/pool.js';
|
|
13
13
|
import { Encryptor } from '../crypto/encryption.js';
|
|
14
14
|
import { Compressor } from '../utils/compression.js';
|
|
15
15
|
import { createDownloadPipeline } from '../utils/download-stream.js';
|
|
@@ -253,9 +253,7 @@ export class ShareServer {
|
|
|
253
253
|
this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
|
|
254
254
|
this.db.init();
|
|
255
255
|
|
|
256
|
-
this.client = new
|
|
257
|
-
await this.client.initialize(this.config.botToken);
|
|
258
|
-
this.client.setChatId(this.config.chatId);
|
|
256
|
+
this.client = new TelegramPool(this.dataDir, this.config.bots);
|
|
259
257
|
|
|
260
258
|
this.encryptor = new Encryptor(this.password);
|
|
261
259
|
this.compressor = new Compressor();
|
|
@@ -337,9 +335,6 @@ export class ShareServer {
|
|
|
337
335
|
return;
|
|
338
336
|
}
|
|
339
337
|
|
|
340
|
-
// Increment download count
|
|
341
|
-
this.db.incrementShareDownload(token);
|
|
342
|
-
|
|
343
338
|
// Determine content type
|
|
344
339
|
const ext = path.extname(fileRecord.filename).toLowerCase();
|
|
345
340
|
const contentTypes = {
|
|
@@ -360,11 +355,14 @@ export class ShareServer {
|
|
|
360
355
|
res.writeHead(200, {
|
|
361
356
|
'Content-Type': contentType,
|
|
362
357
|
'Content-Disposition': `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(fileRecord.filename)}`,
|
|
363
|
-
'
|
|
358
|
+
'Content-Length': fileRecord.original_size
|
|
364
359
|
});
|
|
365
360
|
|
|
366
|
-
// Download the file from Telegram, decrypt, decompress and stream directly to 'res'
|
|
361
|
+
// Download the file from Telegram, decrypt, decompress and stream directly to 'res'.
|
|
362
|
+
// Count the download only AFTER a successful stream so an aborted
|
|
363
|
+
// connection doesn't burn a single-use link.
|
|
367
364
|
await this.streamToResponse(fileRecord, res);
|
|
365
|
+
this.db.incrementShareDownload(token);
|
|
368
366
|
|
|
369
367
|
} catch (err) {
|
|
370
368
|
console.error('Share server error:', err.message);
|