@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/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,14 +54,21 @@ 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
- if (db.exists(hash)) {
62
+ const existingFile = db.findByExactName(filename);
63
+ if (existingFile && existingFile.hash === hash) {
41
64
  db.close();
42
- throw new Error('File already uploaded (duplicate hash)');
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 tempDir = process.env.TAS_TMP_DIR || path.join(dataDir, 'tmp');
54
- if (!fs.existsSync(tempDir)) {
55
- fs.mkdirSync(tempDir, { recursive: true });
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
- // Connect to Telegram
59
- onProgress?.('Connecting to Telegram...');
60
- const client = new TelegramClient(dataDir);
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
- // Helper to upload a single chunk
93
- const uploadCurrentChunk = async (isFinal = false) => {
94
- if (currentChunkBuffer.length === 0 && !isFinal) return; // Nothing to upload
95
- if (currentChunkBuffer.length === 0 && isFinal && chunkIndex > 0) return; // Empty final chunk after perfect split
96
-
97
- // At this point we know if it's the final chunk, so we know the total chunks
98
- const totalChunks = isFinal ? chunkIndex + 1 : Math.max(estimatedChunks, chunkIndex + 1);
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
- async write(chunk, encoding, callback) {
137
- currentChunkBuffer = Buffer.concat([currentChunkBuffer, chunk]);
138
-
139
- // If we exceeded the chunk limit, flush it
140
- if (currentChunkBuffer.length >= TELEGRAM_CHUNK_SIZE) {
141
- const overflow = currentChunkBuffer.subarray(TELEGRAM_CHUNK_SIZE);
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
- async final(callback) {
115
+ final(callback) {
156
116
  try {
157
- await uploadCurrentChunk(true);
117
+ if (currentChunkBuffer.length > 0 || stagedChunks.length === 0) stageChunk(currentChunkBuffer);
158
118
  callback();
159
- } catch (err) {
160
- callback(err);
119
+ } catch (error) {
120
+ callback(error);
161
121
  }
162
122
  }
163
123
  });
164
124
 
165
- const readStream = fs.createReadStream(filePath);
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
- // Run the pipeline: Read -> Compress -> Encrypt -> Chunk & Upload
168
- await pipeline(readStream, compressStream, encryptStream, chunkingStream);
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
- // Update the DB with the final accurate values
171
- db.db.prepare('UPDATE files SET stored_size = ?, chunks = ? WHERE id = ?')
172
- .run(totalStoredSize, chunkIndex, fileId);
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
- db.close();
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
- // Clean up temp dir (only if empty)
173
+ let uploadedBytes = 0;
177
174
  try {
178
- const remaining = fs.readdirSync(tempDir);
179
- if (remaining.length === 0) fs.rmdirSync(tempDir);
180
- } catch (e) {
181
- // Ignore cleanup errors
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: chunkIndex,
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 TelegramClient(dataDir);
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();
@@ -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);