@nightowne/tas-cli 2.4.1 → 3.0.1

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/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 { TelegramClient } from './telegram/client.js';
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
- // Telegram has 50MB limit for bots, 2GB for user uploads
16
- // We'll use 49MB chunks to be safe
17
- const TELEGRAM_CHUNK_SIZE = 49 * 1024 * 1024;
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 { password, dataDir, customName, config, onProgress, onByteProgress } = options;
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
- // Check if already uploaded
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
- try {
41
- if (db.exists(hash)) {
42
- throw new Error('File already uploaded (duplicate hash)');
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
- // Prepare processing components
46
- const compressor = new Compressor();
47
- const { stream: compressStream, compressed } = compressor.getCompressStream(filename);
48
- const flags = compressed ? 1 : 0;
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
- const encryptor = new Encryptor(password);
51
- const encryptStream = encryptor.getEncryptStream();
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
- const tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
54
- if (!fs.existsSync(tempDir)) {
102
+ const chunkingStream = new Writable({
103
+ write(chunk, encoding, callback) {
55
104
  try {
56
- fs.mkdirSync(tempDir, { recursive: true });
57
- } catch (err) {
58
- throw new Error(`Failed to create temporary directory at ${tempDir}: ${err.message}. Check permissions or set TAS_TMP_DIR to a writable location.`);
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
- // Connect to Telegram
63
- onProgress?.('Connecting to Telegram...');
64
- const client = new TelegramClient(dataDir);
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: 0, // Will update later
85
- chunks: estimatedChunks,
86
- compressed
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
- onProgress?.('Processing and uploading streams...');
90
- let uploadedBytes = 0;
91
- let chunkIndex = 0;
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
- // If we exceeded the chunk limit, flush it
144
- if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
145
- const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
146
- currentChunkBuffer = currentChunkBuffer.subarray(0, TELEGRAM_CHUNK_SIZE);
147
-
148
- try {
149
- await uploadCurrentChunk(false);
150
- currentChunkBuffer = overflow; // carry over
151
- callback();
152
- } catch (err) {
153
- callback(err);
154
- }
155
- } else {
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
- const readStream = fs.createReadStream(filePath);
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
- // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
172
- await pipeline(readStream, compressStream, encryptStream, chunkingStream);
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
- // Update the DB with the final accurate values
175
- db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
176
- .run(totalStoredSize, chunkIndex, fileId);
227
+ db.close();
177
228
 
178
- // Clean up temp dir (only if empty)
229
+ let manifestWarning = null;
230
+ if (updateManifest) {
231
+ onProgress?.('Publishing encrypted recovery manifest...');
179
232
  try {
180
- const remaining = fs.readdirSync(tempDir);
181
- if (remaining.length === 0) fs.rmdirSync(tempDir);
182
- } catch (e) {
183
- // Ignore cleanup errors
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 TelegramClient(dataDir);
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();
@@ -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
+ }
@@ -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 { TelegramClient } from '../telegram/client.js';
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 TelegramClient(this.dataDir);
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
- 'Transfer-Encoding': 'chunked'
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);