@nightowne/tas-cli 2.4.1 → 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 +94 -93
- package/package.json +10 -4
- package/src/cli.js +757 -212
- package/src/db/index.js +307 -24
- package/src/fuse/mount.js +336 -175
- package/src/index.js +208 -150
- 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 +13 -12
- 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,175 +54,214 @@ 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
62
|
+
const existingFile = db.findByExactName(filename);
|
|
63
|
+
if (existingFile && existingFile.hash === hash) {
|
|
64
|
+
db.close();
|
|
65
|
+
throw new Error('This logical path already contains the same file');
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
// Prepare processing components
|
|
74
|
+
const compressor = new Compressor();
|
|
75
|
+
const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
|
|
76
|
+
const flags = compressed ? 1 : 0;
|
|
49
77
|
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
const encryptor = new Encryptor(password);
|
|
79
|
+
const encryptStream = encryptor.getEncryptStream();
|
|
80
|
+
|
|
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)}-`));
|
|
84
|
+
|
|
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.
|
|
88
|
+
const { Writable } = await import('stream');
|
|
89
|
+
let currentChunkBuffer = Buffer.alloc(0);
|
|
90
|
+
let totalStoredSize = 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 });
|
|
100
|
+
};
|
|
52
101
|
|
|
53
|
-
|
|
54
|
-
|
|
102
|
+
const chunkingStream = new Writable({
|
|
103
|
+
write(chunk, encoding, callback) {
|
|
55
104
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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);
|
|
109
|
+
}
|
|
110
|
+
callback();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
callback(error);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
final(callback) {
|
|
116
|
+
try {
|
|
117
|
+
if (currentChunkBuffer.length > 0 || stagedChunks.length === 0) stageChunk(currentChunkBuffer);
|
|
118
|
+
callback();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
callback(error);
|
|
59
121
|
}
|
|
60
122
|
}
|
|
123
|
+
});
|
|
124
|
+
|
|
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
|
+
}
|
|
133
|
+
|
|
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
|
+
}
|
|
61
148
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
await client.initialize(config.botToken);
|
|
66
|
-
client.setChatId(config.chatId);
|
|
67
|
-
|
|
68
|
-
// We will stream through a custom Writable chunker
|
|
69
|
-
const { Writable } = await import('stream');
|
|
70
|
-
|
|
71
|
-
// First pass estimation (for calculating total chunks and progress)
|
|
72
|
-
// We don't know the exact final size due to compression and encryption overhead,
|
|
73
|
-
// so we'll estimate total chunks and update it if needed.
|
|
74
|
-
// For small files < 49MB we assume 1 chunk.
|
|
75
|
-
let estimatedSize = compressed ? originalSize : originalSize + 128; // Add encryption overhead
|
|
76
|
-
if (compressed && originalSize > 1024 * 1024) estimatedSize = originalSize * 0.8; // Rough guess
|
|
77
|
-
let estimatedChunks = Math.ceil(estimatedSize / TELEGRAM_CHUNK_SIZE) || 1;
|
|
78
|
-
|
|
79
|
-
// Register file in DB
|
|
80
|
-
const fileId = db.addFile({
|
|
149
|
+
let pendingId;
|
|
150
|
+
try {
|
|
151
|
+
pendingId = db.addPendingUpload({
|
|
81
152
|
filename,
|
|
153
|
+
filePath,
|
|
82
154
|
hash,
|
|
83
155
|
originalSize,
|
|
84
|
-
storedSize:
|
|
85
|
-
|
|
86
|
-
|
|
156
|
+
storedSize: totalStoredSize,
|
|
157
|
+
compressed,
|
|
158
|
+
totalChunks,
|
|
159
|
+
uploadedChunks: 0,
|
|
160
|
+
tempDir: uploadDir
|
|
87
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
|
+
}
|
|
88
168
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
let currentChunkBuffer = Buffer.alloc(0);
|
|
94
|
-
let totalStoredSize = 0;
|
|
95
|
-
|
|
96
|
-
// Helper to upload a single chunk
|
|
97
|
-
const uploadCurrentChunk = async (isFinal = false) => {
|
|
98
|
-
if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
|
|
99
|
-
if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
|
|
100
|
-
|
|
101
|
-
// At this point we know if it's the final chunk, so we know the total chunks
|
|
102
|
-
const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
|
|
103
|
-
|
|
104
|
-
const header = createHeader(filename, originalSize, chunkIndex, totalChunks, flags);
|
|
105
|
-
const chunkData = Buffer.concat([header, currentChunkBuffer]);
|
|
106
|
-
|
|
107
|
-
const chunkFilename = totalChunks > 1
|
|
108
|
-
? `${hash.substring(0, 12)}.part${chunkIndex}.tas`
|
|
109
|
-
: `${hash.substring(0, 12)}.tas`;
|
|
110
|
-
|
|
111
|
-
const chunkPath = path.join(tempDir, chunkFilename);
|
|
112
|
-
fs.writeFileSync(chunkPath, chunkData);
|
|
113
|
-
|
|
114
|
-
const caption = totalChunks > 1
|
|
115
|
-
? `📦 ${filename} (${chunkIndex + 1}/${totalChunks})`
|
|
116
|
-
: `📦 ${filename}`;
|
|
117
|
-
|
|
118
|
-
onProgress?.(`Uploading chunk ${chunkIndex + 1}...`);
|
|
119
|
-
|
|
120
|
-
const result = await client.sendFile(chunkPath, caption);
|
|
121
|
-
|
|
122
|
-
uploadedBytes += chunkData.length;
|
|
123
|
-
totalStoredSize += currentChunkBuffer.length;
|
|
124
|
-
|
|
125
|
-
onByteProgress?.({ uploaded: uploadedBytes, total: estimatedSize, chunk: chunkIndex + 1, totalChunks });
|
|
126
|
-
|
|
127
|
-
// Store file_id
|
|
128
|
-
db.addChunk(fileId, chunkIndex, result.messageId.toString(), chunkData.length);
|
|
129
|
-
db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
130
|
-
.run(result.fileId, fileId, chunkIndex);
|
|
131
|
-
|
|
132
|
-
// Clean up temp file immediately to save disk space
|
|
133
|
-
fs.unlinkSync(chunkPath);
|
|
134
|
-
|
|
135
|
-
chunkIndex++;
|
|
136
|
-
currentChunkBuffer = Buffer.alloc(0);
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const chunkingStream = new Writable({
|
|
140
|
-
async write(chunk, encoding, callback) {
|
|
141
|
-
currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
|
|
169
|
+
onProgress?.('Connecting to Telegram...');
|
|
170
|
+
const client = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
171
|
+
if (!telegramPool) await client.initialize({ includeDisabled: false });
|
|
142
172
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
callback();
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
async final(callback) {
|
|
160
|
-
try {
|
|
161
|
-
await uploadCurrentChunk(true);
|
|
162
|
-
callback();
|
|
163
|
-
} catch (err) {
|
|
164
|
-
callback(err);
|
|
173
|
+
let uploadedBytes = 0;
|
|
174
|
+
try {
|
|
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
|
|
165
186
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
}
|
|
170
208
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
})();
|
|
173
226
|
|
|
174
|
-
|
|
175
|
-
db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
|
|
176
|
-
.run(totalStoredSize, chunkIndex, fileId);
|
|
227
|
+
db.close();
|
|
177
228
|
|
|
178
|
-
|
|
229
|
+
let manifestWarning = null;
|
|
230
|
+
if (updateManifest) {
|
|
231
|
+
onProgress?.('Publishing encrypted recovery manifest...');
|
|
179
232
|
try {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
184
239
|
}
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
filename,
|
|
188
|
-
hash,
|
|
189
|
-
originalSize,
|
|
190
|
-
storedSize: totalStoredSize,
|
|
191
|
-
chunks: chunkIndex,
|
|
192
|
-
compressed
|
|
193
|
-
};
|
|
194
|
-
} finally {
|
|
195
|
-
// Always close DB connection, even on error
|
|
196
|
-
db.close();
|
|
197
240
|
}
|
|
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
|
+
|
|
248
|
+
return {
|
|
249
|
+
filename,
|
|
250
|
+
hash,
|
|
251
|
+
originalSize,
|
|
252
|
+
storedSize: totalStoredSize,
|
|
253
|
+
chunks: totalChunks,
|
|
254
|
+
compressed,
|
|
255
|
+
manifestWarning,
|
|
256
|
+
supersededChunks: existingChunks
|
|
257
|
+
};
|
|
198
258
|
}
|
|
199
259
|
|
|
200
260
|
/**
|
|
201
261
|
* Retrieve a file from Telegram
|
|
202
262
|
*/
|
|
203
263
|
export async function retrieveFile(fileRecord, options) {
|
|
204
|
-
const { password, dataDir, outputPath, config, onProgress, onByteProgress } = options;
|
|
264
|
+
const { password, dataDir, outputPath, config, onProgress, onByteProgress, telegramPool } = options;
|
|
205
265
|
|
|
206
266
|
onProgress?.('Connecting to Telegram...');
|
|
207
267
|
|
|
@@ -217,9 +277,7 @@ export async function retrieveFile(fileRecord, options) {
|
|
|
217
277
|
}
|
|
218
278
|
|
|
219
279
|
// Connect to Telegram
|
|
220
|
-
const client = new
|
|
221
|
-
await client.initialize(config.botToken);
|
|
222
|
-
client.setChatId(config.chatId);
|
|
280
|
+
const client = telegramPool || new TelegramPool(dataDir, config.bots);
|
|
223
281
|
|
|
224
282
|
const encryptor = new Encryptor(password);
|
|
225
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);
|