@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/CHANGELOG.md +162 -0
- package/FAQ.md +33 -0
- package/QUICKSTART.md +47 -0
- package/README.md +94 -93
- package/package.json +12 -5
- package/src/cli.js +760 -214
- package/src/db/index.js +307 -24
- package/src/fuse/macfuse.cjs +235 -0
- package/src/fuse/mount.js +338 -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/db/index.js
CHANGED
|
@@ -20,13 +20,18 @@ export class FileIndex {
|
|
|
20
20
|
|
|
21
21
|
// Enable WAL mode for better concurrent access
|
|
22
22
|
this.db.pragma('journal_mode = WAL');
|
|
23
|
+
// Enforce foreign keys so ON DELETE CASCADE actually works
|
|
24
|
+
// (SQLite disables FK enforcement by default per connection)
|
|
25
|
+
this.db.pragma('foreign_keys = ON');
|
|
26
|
+
// Don't fail instantly when sync workers write concurrently
|
|
27
|
+
this.db.pragma('busy_timeout = 5000');
|
|
23
28
|
|
|
24
29
|
// Create files table
|
|
25
30
|
this.db.exec(`
|
|
26
31
|
CREATE TABLE IF NOT EXISTS files (
|
|
27
32
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
33
|
filename TEXT NOT NULL,
|
|
29
|
-
hash TEXT
|
|
34
|
+
hash TEXT NOT NULL,
|
|
30
35
|
original_size INTEGER NOT NULL,
|
|
31
36
|
stored_size INTEGER NOT NULL,
|
|
32
37
|
chunks INTEGER NOT NULL DEFAULT 1,
|
|
@@ -39,6 +44,8 @@ export class FileIndex {
|
|
|
39
44
|
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
|
|
40
45
|
`);
|
|
41
46
|
|
|
47
|
+
this._removeLegacyUniqueHashConstraint();
|
|
48
|
+
|
|
42
49
|
// Create chunks table (for multi-part files)
|
|
43
50
|
this.db.exec(`
|
|
44
51
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
@@ -47,11 +54,13 @@ export class FileIndex {
|
|
|
47
54
|
chunk_index INTEGER NOT NULL,
|
|
48
55
|
message_id TEXT NOT NULL,
|
|
49
56
|
file_telegram_id TEXT,
|
|
57
|
+
bot_id TEXT,
|
|
50
58
|
size INTEGER NOT NULL,
|
|
51
59
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
52
60
|
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
|
|
53
61
|
UNIQUE(file_id, chunk_index)
|
|
54
62
|
);
|
|
63
|
+
|
|
55
64
|
`);
|
|
56
65
|
|
|
57
66
|
// Create tags table for file organization
|
|
@@ -116,11 +125,12 @@ export class FileIndex {
|
|
|
116
125
|
file_path TEXT NOT NULL,
|
|
117
126
|
hash TEXT NOT NULL,
|
|
118
127
|
original_size INTEGER NOT NULL,
|
|
128
|
+
stored_size INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
compressed INTEGER NOT NULL DEFAULT 0,
|
|
119
130
|
total_chunks INTEGER NOT NULL,
|
|
120
131
|
uploaded_chunks INTEGER NOT NULL DEFAULT 0,
|
|
121
132
|
temp_dir TEXT,
|
|
122
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
123
|
-
UNIQUE(hash)
|
|
133
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
124
134
|
);
|
|
125
135
|
|
|
126
136
|
CREATE TABLE IF NOT EXISTS pending_chunks (
|
|
@@ -131,10 +141,126 @@ export class FileIndex {
|
|
|
131
141
|
uploaded INTEGER NOT NULL DEFAULT 0,
|
|
132
142
|
message_id TEXT,
|
|
133
143
|
file_telegram_id TEXT,
|
|
144
|
+
bot_id TEXT,
|
|
145
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
134
146
|
FOREIGN KEY (pending_id) REFERENCES pending_uploads(id) ON DELETE CASCADE,
|
|
135
147
|
UNIQUE(pending_id, chunk_index)
|
|
136
148
|
);
|
|
137
149
|
`);
|
|
150
|
+
|
|
151
|
+
// Online migration for databases created before multi-bot support.
|
|
152
|
+
// NULL means the legacy `primary` bot and is intentionally not rewritten.
|
|
153
|
+
const chunkColumns = this.db.pragma('table_info(chunks)').map(column => column.name);
|
|
154
|
+
if (!chunkColumns.includes('bot_id')) {
|
|
155
|
+
this.db.exec('ALTER TABLE chunks ADD COLUMN bot_id TEXT');
|
|
156
|
+
}
|
|
157
|
+
this.db.exec('CREATE INDEX IF NOT EXISTS idx_chunks_bot_id ON chunks(bot_id)');
|
|
158
|
+
const pendingChunkColumns = this.db.pragma('table_info(pending_chunks)').map(column => column.name);
|
|
159
|
+
if (!pendingChunkColumns.includes('bot_id')) {
|
|
160
|
+
this.db.exec('ALTER TABLE pending_chunks ADD COLUMN bot_id TEXT');
|
|
161
|
+
}
|
|
162
|
+
if (!pendingChunkColumns.includes('size')) {
|
|
163
|
+
this.db.exec('ALTER TABLE pending_chunks ADD COLUMN size INTEGER NOT NULL DEFAULT 0');
|
|
164
|
+
}
|
|
165
|
+
const pendingUploadColumns = this.db.pragma('table_info(pending_uploads)').map(column => column.name);
|
|
166
|
+
if (!pendingUploadColumns.includes('stored_size')) {
|
|
167
|
+
this.db.exec('ALTER TABLE pending_uploads ADD COLUMN stored_size INTEGER NOT NULL DEFAULT 0');
|
|
168
|
+
}
|
|
169
|
+
if (!pendingUploadColumns.includes('compressed')) {
|
|
170
|
+
this.db.exec('ALTER TABLE pending_uploads ADD COLUMN compressed INTEGER NOT NULL DEFAULT 0');
|
|
171
|
+
}
|
|
172
|
+
this._removeLegacyPendingHashConstraint();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* v1-v2.5 made content hashes UNIQUE, which prevented the same bytes from
|
|
177
|
+
* being stored at two logical paths and could orphan FUSE uploads. Rebuild
|
|
178
|
+
* only legacy tables that still carry that constraint.
|
|
179
|
+
*/
|
|
180
|
+
_removeLegacyUniqueHashConstraint() {
|
|
181
|
+
const hasUniqueHash = this.db.pragma('index_list(files)').some(index => {
|
|
182
|
+
if (!index.unique) return false;
|
|
183
|
+
const columns = this.db.pragma(`index_info('${index.name.replaceAll("'", "''")}')`);
|
|
184
|
+
return columns.length === 1 && columns[0].name === 'hash';
|
|
185
|
+
});
|
|
186
|
+
if (!hasUniqueHash) return;
|
|
187
|
+
|
|
188
|
+
this.db.pragma('foreign_keys = OFF');
|
|
189
|
+
this.db.pragma('legacy_alter_table = ON');
|
|
190
|
+
try {
|
|
191
|
+
this.db.exec(`
|
|
192
|
+
BEGIN;
|
|
193
|
+
ALTER TABLE files RENAME TO files_legacy_unique_hash;
|
|
194
|
+
CREATE TABLE files (
|
|
195
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
196
|
+
filename TEXT NOT NULL,
|
|
197
|
+
hash TEXT NOT NULL,
|
|
198
|
+
original_size INTEGER NOT NULL,
|
|
199
|
+
stored_size INTEGER NOT NULL,
|
|
200
|
+
chunks INTEGER NOT NULL DEFAULT 1,
|
|
201
|
+
compressed INTEGER NOT NULL DEFAULT 0,
|
|
202
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
203
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
204
|
+
);
|
|
205
|
+
INSERT INTO files
|
|
206
|
+
(id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at)
|
|
207
|
+
SELECT id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at
|
|
208
|
+
FROM files_legacy_unique_hash;
|
|
209
|
+
DROP TABLE files_legacy_unique_hash;
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_files_filename ON files(filename);
|
|
211
|
+
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(hash);
|
|
212
|
+
COMMIT;
|
|
213
|
+
`);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
try { this.db.exec('ROLLBACK'); } catch { }
|
|
216
|
+
throw error;
|
|
217
|
+
} finally {
|
|
218
|
+
this.db.pragma('legacy_alter_table = OFF');
|
|
219
|
+
this.db.pragma('foreign_keys = ON');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_removeLegacyPendingHashConstraint() {
|
|
224
|
+
const hasUniqueHash = this.db.pragma('index_list(pending_uploads)').some(index => {
|
|
225
|
+
if (!index.unique) return false;
|
|
226
|
+
const columns = this.db.pragma(`index_info('${index.name.replaceAll("'", "''")}')`);
|
|
227
|
+
return columns.length === 1 && columns[0].name === 'hash';
|
|
228
|
+
});
|
|
229
|
+
if (!hasUniqueHash) return;
|
|
230
|
+
|
|
231
|
+
this.db.pragma('foreign_keys = OFF');
|
|
232
|
+
this.db.pragma('legacy_alter_table = ON');
|
|
233
|
+
try {
|
|
234
|
+
this.db.exec(`
|
|
235
|
+
BEGIN;
|
|
236
|
+
ALTER TABLE pending_uploads RENAME TO pending_uploads_legacy_unique_hash;
|
|
237
|
+
CREATE TABLE pending_uploads (
|
|
238
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
239
|
+
filename TEXT NOT NULL,
|
|
240
|
+
file_path TEXT NOT NULL,
|
|
241
|
+
hash TEXT NOT NULL,
|
|
242
|
+
original_size INTEGER NOT NULL,
|
|
243
|
+
stored_size INTEGER NOT NULL DEFAULT 0,
|
|
244
|
+
compressed INTEGER NOT NULL DEFAULT 0,
|
|
245
|
+
total_chunks INTEGER NOT NULL,
|
|
246
|
+
uploaded_chunks INTEGER NOT NULL DEFAULT 0,
|
|
247
|
+
temp_dir TEXT,
|
|
248
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
249
|
+
);
|
|
250
|
+
INSERT INTO pending_uploads
|
|
251
|
+
(id, filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir, created_at)
|
|
252
|
+
SELECT id, filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir, created_at
|
|
253
|
+
FROM pending_uploads_legacy_unique_hash;
|
|
254
|
+
DROP TABLE pending_uploads_legacy_unique_hash;
|
|
255
|
+
COMMIT;
|
|
256
|
+
`);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
try { this.db.exec('ROLLBACK'); } catch { }
|
|
259
|
+
throw error;
|
|
260
|
+
} finally {
|
|
261
|
+
this.db.pragma('legacy_alter_table = OFF');
|
|
262
|
+
this.db.pragma('foreign_keys = ON');
|
|
263
|
+
}
|
|
138
264
|
}
|
|
139
265
|
|
|
140
266
|
/**
|
|
@@ -161,13 +287,35 @@ export class FileIndex {
|
|
|
161
287
|
/**
|
|
162
288
|
* Add chunk metadata
|
|
163
289
|
*/
|
|
164
|
-
addChunk(fileId, chunkIndex, messageId, size) {
|
|
290
|
+
addChunk(fileId, chunkIndex, messageId, size, fileTelegramId = null, botId = null) {
|
|
165
291
|
const stmt = this.db.prepare(`
|
|
166
|
-
INSERT INTO chunks (file_id, chunk_index, message_id, size)
|
|
167
|
-
VALUES (?, ?, ?, ?)
|
|
292
|
+
INSERT INTO chunks (file_id, chunk_index, message_id, size, file_telegram_id, bot_id)
|
|
293
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
168
294
|
`);
|
|
169
295
|
|
|
170
|
-
stmt.run(fileId, chunkIndex, messageId, size);
|
|
296
|
+
stmt.run(fileId, chunkIndex, messageId, size, fileTelegramId, botId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Number of chunks that depend on a configured bot. */
|
|
300
|
+
countChunksByBot(botId) {
|
|
301
|
+
if (botId === 'primary') {
|
|
302
|
+
return this.db.prepare(`
|
|
303
|
+
SELECT COUNT(*) AS count FROM chunks WHERE bot_id = ? OR bot_id IS NULL
|
|
304
|
+
`).get(botId).count;
|
|
305
|
+
}
|
|
306
|
+
return this.db.prepare('SELECT COUNT(*) AS count FROM chunks WHERE bot_id = ?').get(botId).count;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
countPendingChunksByBot(botId) {
|
|
310
|
+
if (botId === 'primary') {
|
|
311
|
+
return this.db.prepare(`
|
|
312
|
+
SELECT COUNT(*) AS count FROM pending_chunks
|
|
313
|
+
WHERE uploaded = 1 AND (bot_id = ? OR bot_id IS NULL)
|
|
314
|
+
`).get(botId).count;
|
|
315
|
+
}
|
|
316
|
+
return this.db.prepare(`
|
|
317
|
+
SELECT COUNT(*) AS count FROM pending_chunks WHERE uploaded = 1 AND bot_id = ?
|
|
318
|
+
`).get(botId).count;
|
|
171
319
|
}
|
|
172
320
|
|
|
173
321
|
/**
|
|
@@ -178,25 +326,70 @@ export class FileIndex {
|
|
|
178
326
|
}
|
|
179
327
|
|
|
180
328
|
/**
|
|
181
|
-
* Find file by hash (exact match
|
|
329
|
+
* Find file by hash (exact match preferred, then prefix match)
|
|
182
330
|
*/
|
|
183
331
|
findByHash(hash) {
|
|
332
|
+
const exact = this.db.prepare('SELECT * FROM files WHERE hash = ?').get(hash);
|
|
333
|
+
if (exact) return exact;
|
|
334
|
+
|
|
184
335
|
const stmt = this.db.prepare(`
|
|
185
|
-
SELECT * FROM files WHERE hash
|
|
336
|
+
SELECT * FROM files WHERE hash LIKE ? ESCAPE '\\'
|
|
186
337
|
`);
|
|
187
338
|
|
|
188
|
-
return stmt.get(
|
|
339
|
+
return stmt.get(this._escapeLike(hash) + '%');
|
|
189
340
|
}
|
|
190
341
|
|
|
191
342
|
/**
|
|
192
|
-
* Find file by filename (exact match
|
|
343
|
+
* Find file by filename (exact match preferred, then substring match)
|
|
344
|
+
* Preferring exact matches avoids returning an arbitrary row when
|
|
345
|
+
* duplicate filenames exist in the index.
|
|
193
346
|
*/
|
|
194
347
|
findByName(filename) {
|
|
348
|
+
const exact = this.db.prepare('SELECT * FROM files WHERE filename = ?').get(filename);
|
|
349
|
+
if (exact) return exact;
|
|
350
|
+
|
|
351
|
+
const stmt = this.db.prepare(`
|
|
352
|
+
SELECT * FROM files WHERE filename LIKE ? ESCAPE '\\'
|
|
353
|
+
`);
|
|
354
|
+
|
|
355
|
+
return stmt.get('%' + this._escapeLike(filename) + '%');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Exact logical-path lookup. Required by FUSE; never falls back to LIKE. */
|
|
359
|
+
findByExactName(filename) {
|
|
360
|
+
return this.db.prepare('SELECT * FROM files WHERE filename = ? ORDER BY id DESC LIMIT 1').get(filename);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Find files whose chunk rows don't match the expected chunk count.
|
|
365
|
+
* These are leftovers from interrupted uploads (see processFile cleanup).
|
|
366
|
+
* Used by `tas resume` to offer cleanup/retry.
|
|
367
|
+
*/
|
|
368
|
+
getIncompleteUploads() {
|
|
195
369
|
const stmt = this.db.prepare(`
|
|
196
|
-
SELECT
|
|
370
|
+
SELECT f.*, COUNT(c.id) as actual_chunks
|
|
371
|
+
FROM files f
|
|
372
|
+
LEFT JOIN chunks c ON c.file_id = f.id
|
|
373
|
+
GROUP BY f.id
|
|
374
|
+
HAVING actual_chunks != f.chunks OR f.stored_size = 0
|
|
197
375
|
`);
|
|
376
|
+
return stmt.all();
|
|
377
|
+
}
|
|
198
378
|
|
|
199
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Delete a file and all its chunk rows explicitly.
|
|
381
|
+
* Explicit deletes keep things correct even on connections
|
|
382
|
+
* where FK enforcement was not enabled (older DBs).
|
|
383
|
+
*/
|
|
384
|
+
deleteFileCascade(fileId) {
|
|
385
|
+
const delChunks = this.db.prepare('DELETE FROM chunks WHERE file_id = ?');
|
|
386
|
+
delChunks.run(fileId);
|
|
387
|
+
const delTags = this.db.prepare('DELETE FROM tags WHERE file_id = ?');
|
|
388
|
+
try { delTags.run(fileId); } catch { /* tags table may not exist on very old DBs */ }
|
|
389
|
+
const delShares = this.db.prepare('DELETE FROM shares WHERE file_id = ?');
|
|
390
|
+
try { delShares.run(fileId); } catch { /* ignore */ }
|
|
391
|
+
const stmt = this.db.prepare('DELETE FROM files WHERE id = ?');
|
|
392
|
+
stmt.run(fileId);
|
|
200
393
|
}
|
|
201
394
|
|
|
202
395
|
/**
|
|
@@ -253,6 +446,94 @@ export class FileIndex {
|
|
|
253
446
|
return stmt.get();
|
|
254
447
|
}
|
|
255
448
|
|
|
449
|
+
/** Export only durable storage metadata required to rebuild the local index. */
|
|
450
|
+
exportManifest({ includeShares = false } = {}) {
|
|
451
|
+
const files = this.db.prepare('SELECT * FROM files ORDER BY id').all();
|
|
452
|
+
const chunks = this.db.prepare('SELECT * FROM chunks ORDER BY file_id, chunk_index').all();
|
|
453
|
+
const tags = this.db.prepare('SELECT file_id, tag, created_at FROM tags ORDER BY file_id, tag').all();
|
|
454
|
+
const manifest = {
|
|
455
|
+
schemaVersion: 1,
|
|
456
|
+
createdAt: new Date().toISOString(),
|
|
457
|
+
files,
|
|
458
|
+
chunks,
|
|
459
|
+
tags
|
|
460
|
+
};
|
|
461
|
+
if (includeShares) manifest.shares = this.db.prepare('SELECT * FROM shares ORDER BY id').all();
|
|
462
|
+
return manifest;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Replace storage metadata from a validated decrypted remote manifest. */
|
|
466
|
+
importManifest(manifest) {
|
|
467
|
+
if (!manifest || manifest.schemaVersion !== 1 || !Array.isArray(manifest.files) || !Array.isArray(manifest.chunks)) {
|
|
468
|
+
throw new Error('Unsupported or malformed TAS manifest');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const insertFile = this.db.prepare(`
|
|
472
|
+
INSERT INTO files
|
|
473
|
+
(id, filename, hash, original_size, stored_size, chunks, compressed, created_at, updated_at)
|
|
474
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
475
|
+
`);
|
|
476
|
+
const insertChunk = this.db.prepare(`
|
|
477
|
+
INSERT INTO chunks
|
|
478
|
+
(id, file_id, chunk_index, message_id, file_telegram_id, bot_id, size, created_at)
|
|
479
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
480
|
+
`);
|
|
481
|
+
const insertTag = this.db.prepare(`
|
|
482
|
+
INSERT OR IGNORE INTO tags (file_id, tag, created_at) VALUES (?, ?, ?)
|
|
483
|
+
`);
|
|
484
|
+
const insertShare = this.db.prepare(`
|
|
485
|
+
INSERT INTO shares
|
|
486
|
+
(id, file_id, token, expires_at, max_downloads, download_count, created_at)
|
|
487
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
488
|
+
`);
|
|
489
|
+
|
|
490
|
+
this.db.transaction(() => {
|
|
491
|
+
if (Array.isArray(manifest.shares)) this.db.prepare('DELETE FROM shares').run();
|
|
492
|
+
this.db.prepare('DELETE FROM tags').run();
|
|
493
|
+
this.db.prepare('DELETE FROM chunks').run();
|
|
494
|
+
this.db.prepare('DELETE FROM files').run();
|
|
495
|
+
for (const file of manifest.files) {
|
|
496
|
+
insertFile.run(
|
|
497
|
+
file.id,
|
|
498
|
+
file.filename,
|
|
499
|
+
file.hash,
|
|
500
|
+
file.original_size,
|
|
501
|
+
file.stored_size,
|
|
502
|
+
file.chunks,
|
|
503
|
+
file.compressed,
|
|
504
|
+
file.created_at,
|
|
505
|
+
file.updated_at
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
for (const chunk of manifest.chunks) {
|
|
509
|
+
insertChunk.run(
|
|
510
|
+
chunk.id,
|
|
511
|
+
chunk.file_id,
|
|
512
|
+
chunk.chunk_index,
|
|
513
|
+
chunk.message_id,
|
|
514
|
+
chunk.file_telegram_id,
|
|
515
|
+
chunk.bot_id || null,
|
|
516
|
+
chunk.size,
|
|
517
|
+
chunk.created_at
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
for (const tag of manifest.tags || []) {
|
|
521
|
+
insertTag.run(tag.file_id, tag.tag, tag.created_at);
|
|
522
|
+
}
|
|
523
|
+
for (const share of manifest.shares || []) {
|
|
524
|
+
insertShare.run(
|
|
525
|
+
share.id,
|
|
526
|
+
share.file_id,
|
|
527
|
+
share.token,
|
|
528
|
+
share.expires_at,
|
|
529
|
+
share.max_downloads,
|
|
530
|
+
share.download_count,
|
|
531
|
+
share.created_at
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
}
|
|
536
|
+
|
|
256
537
|
// ============== TAG METHODS ==============
|
|
257
538
|
|
|
258
539
|
/**
|
|
@@ -436,14 +717,16 @@ export class FileIndex {
|
|
|
436
717
|
addPendingUpload(data) {
|
|
437
718
|
const stmt = this.db.prepare(`
|
|
438
719
|
INSERT OR REPLACE INTO pending_uploads
|
|
439
|
-
(filename, file_path, hash, original_size, total_chunks, uploaded_chunks, temp_dir)
|
|
440
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
720
|
+
(filename, file_path, hash, original_size, stored_size, compressed, total_chunks, uploaded_chunks, temp_dir)
|
|
721
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
441
722
|
`);
|
|
442
723
|
const result = stmt.run(
|
|
443
724
|
data.filename,
|
|
444
725
|
data.filePath,
|
|
445
726
|
data.hash,
|
|
446
727
|
data.originalSize,
|
|
728
|
+
data.storedSize || 0,
|
|
729
|
+
data.compressed ? 1 : 0,
|
|
447
730
|
data.totalChunks,
|
|
448
731
|
data.uploadedChunks || 0,
|
|
449
732
|
data.tempDir
|
|
@@ -454,27 +737,27 @@ export class FileIndex {
|
|
|
454
737
|
/**
|
|
455
738
|
* Add a pending chunk
|
|
456
739
|
*/
|
|
457
|
-
addPendingChunk(pendingId, chunkIndex, chunkPath) {
|
|
740
|
+
addPendingChunk(pendingId, chunkIndex, chunkPath, size = 0) {
|
|
458
741
|
const stmt = this.db.prepare(`
|
|
459
|
-
INSERT OR REPLACE INTO pending_chunks (pending_id, chunk_index, chunk_path, uploaded)
|
|
460
|
-
VALUES (?, ?, ?, 0)
|
|
742
|
+
INSERT OR REPLACE INTO pending_chunks (pending_id, chunk_index, chunk_path, size, uploaded)
|
|
743
|
+
VALUES (?, ?, ?, ?, 0)
|
|
461
744
|
`);
|
|
462
|
-
stmt.run(pendingId, chunkIndex, chunkPath);
|
|
745
|
+
stmt.run(pendingId, chunkIndex, chunkPath, size);
|
|
463
746
|
}
|
|
464
747
|
|
|
465
748
|
/**
|
|
466
749
|
* Mark chunk as uploaded
|
|
467
750
|
*/
|
|
468
|
-
markChunkUploaded(pendingId, chunkIndex, messageId, fileTelegramId) {
|
|
751
|
+
markChunkUploaded(pendingId, chunkIndex, messageId, fileTelegramId, botId = null) {
|
|
469
752
|
const stmt = this.db.prepare(`
|
|
470
753
|
UPDATE pending_chunks
|
|
471
|
-
SET uploaded = 1, message_id = ?, file_telegram_id = ?
|
|
472
|
-
WHERE pending_id = ? AND chunk_index = ?
|
|
754
|
+
SET uploaded = 1, message_id = ?, file_telegram_id = ?, bot_id = ?
|
|
755
|
+
WHERE pending_id = ? AND chunk_index = ? AND uploaded = 0
|
|
473
756
|
`);
|
|
474
|
-
stmt.run(messageId, fileTelegramId, pendingId, chunkIndex);
|
|
757
|
+
const result = stmt.run(messageId, fileTelegramId, botId, pendingId, chunkIndex);
|
|
475
758
|
|
|
476
759
|
// Update uploaded count
|
|
477
|
-
this.db.prepare(`
|
|
760
|
+
if (result.changes > 0) this.db.prepare(`
|
|
478
761
|
UPDATE pending_uploads SET uploaded_chunks = uploaded_chunks + 1 WHERE id = ?
|
|
479
762
|
`).run(pendingId);
|
|
480
763
|
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Modern macFUSE integration for fuse-native 2.x.
|
|
4
|
+
//
|
|
5
|
+
// fuse-native bundles an OSXFUSE 3 dylib on Darwin. That dylib has no arm64
|
|
6
|
+
// slice and its setup helper installs an obsolete kernel extension. We never
|
|
7
|
+
// install or load it. Instead, when macFUSE is already installed by the user,
|
|
8
|
+
// the TAS postinstall hook rebuilds fuse-native against that system library.
|
|
9
|
+
|
|
10
|
+
const childProcess = require('child_process')
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
|
|
14
|
+
const MACFUSE_BUNDLE = '/Library/Filesystems/macfuse.fs'
|
|
15
|
+
|
|
16
|
+
const LIBRARY_CANDIDATES = [
|
|
17
|
+
'/usr/local/lib/libfuse.dylib',
|
|
18
|
+
'/opt/homebrew/lib/libfuse.dylib',
|
|
19
|
+
'/usr/local/lib/libfuse.2.dylib',
|
|
20
|
+
'/opt/homebrew/lib/libfuse.2.dylib',
|
|
21
|
+
'/Library/Frameworks/macfUSE.framework/Versions/Current/lib/libfuse.dylib',
|
|
22
|
+
'/Library/Frameworks/macfUSE.framework/lib/libfuse.dylib'
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const INCLUDE_ROOTS = [
|
|
26
|
+
'/usr/local/include',
|
|
27
|
+
'/opt/homebrew/include',
|
|
28
|
+
'/Library/Frameworks/macfUSE.framework/Headers'
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
function findMacFuseInstallation ({ exists = fs.existsSync } = {}) {
|
|
32
|
+
if (!exists(MACFUSE_BUNDLE)) {
|
|
33
|
+
return {
|
|
34
|
+
ready: false,
|
|
35
|
+
reason: 'current macFUSE is not installed at /Library/Filesystems/macfuse.fs'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const library = LIBRARY_CANDIDATES.find(exists)
|
|
40
|
+
if (!library) {
|
|
41
|
+
return {
|
|
42
|
+
ready: false,
|
|
43
|
+
reason: 'macFUSE is installed but its libfuse.dylib was not found'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const include = findIncludeDirectory(exists)
|
|
48
|
+
if (!include) {
|
|
49
|
+
return {
|
|
50
|
+
ready: false,
|
|
51
|
+
reason: 'macFUSE is installed but its FUSE development headers were not found'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ready: true, bundle: MACFUSE_BUNDLE, library, include }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findIncludeDirectory (exists) {
|
|
59
|
+
for (const root of INCLUDE_ROOTS) {
|
|
60
|
+
for (const candidate of [
|
|
61
|
+
path.join(root, 'fuse.h'),
|
|
62
|
+
path.join(root, 'osxfuse', 'fuse.h'),
|
|
63
|
+
path.join(root, 'fuse', 'fuse.h')
|
|
64
|
+
]) {
|
|
65
|
+
if (exists(candidate)) return path.dirname(candidate)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getFuseNativeDirectory (packageRoot) {
|
|
72
|
+
try {
|
|
73
|
+
return path.dirname(require.resolve('fuse-native/package.json', { paths: [packageRoot] }))
|
|
74
|
+
} catch {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getDarwinLibraryDirectory (packageRoot) {
|
|
80
|
+
try {
|
|
81
|
+
return path.dirname(require.resolve('fuse-shared-library-darwin/package.json', { paths: [packageRoot] }))
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateMacFuseNativeBinding ({
|
|
88
|
+
packageRoot = path.resolve(__dirname, '..', '..'),
|
|
89
|
+
exists = fs.existsSync,
|
|
90
|
+
execFileSync = childProcess.execFileSync
|
|
91
|
+
} = {}) {
|
|
92
|
+
const installation = findMacFuseInstallation({ exists })
|
|
93
|
+
if (!installation.ready) return installation
|
|
94
|
+
|
|
95
|
+
const fuseNativeDir = getFuseNativeDirectory(packageRoot)
|
|
96
|
+
if (!fuseNativeDir) {
|
|
97
|
+
return { ready: false, reason: 'fuse-native is not installed' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const addon = path.join(fuseNativeDir, 'build', 'Release', 'fuse.node')
|
|
101
|
+
if (!exists(addon)) {
|
|
102
|
+
return {
|
|
103
|
+
ready: false,
|
|
104
|
+
reason: 'the macFUSE native addon was not rebuilt from source; reinstall TAS after installing Xcode Command Line Tools'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let linkedLibraries
|
|
109
|
+
try {
|
|
110
|
+
linkedLibraries = execFileSync('/usr/bin/otool', ['-L', addon], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
111
|
+
} catch {
|
|
112
|
+
return { ready: false, reason: 'could not inspect the macFUSE native addon' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (linkedLibraries.includes('libosxfuse')) {
|
|
116
|
+
return { ready: false, reason: 'the legacy OSXFUSE binary is still loaded; reinstall TAS to rebuild against macFUSE' }
|
|
117
|
+
}
|
|
118
|
+
if (!linkedLibraries.includes('libfuse')) {
|
|
119
|
+
return { ready: false, reason: 'the native addon is not linked to macFUSE libfuse' }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ready: true, ...installation, addon }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function macFuseAdapterSource () {
|
|
126
|
+
return `'use strict'
|
|
127
|
+
const path = require('path')
|
|
128
|
+
const macfuse = require(path.join(__dirname, '..', '..', 'src', 'fuse', 'macfuse.cjs'))
|
|
129
|
+
const installation = macfuse.findMacFuseInstallation()
|
|
130
|
+
|
|
131
|
+
function unavailable () {
|
|
132
|
+
return new Error('Current macFUSE is required for TAS mount: ' + installation.reason)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
get lib () {
|
|
137
|
+
if (!installation.ready) throw unavailable()
|
|
138
|
+
return installation.library
|
|
139
|
+
},
|
|
140
|
+
get include () {
|
|
141
|
+
if (!installation.ready) throw unavailable()
|
|
142
|
+
return installation.include
|
|
143
|
+
},
|
|
144
|
+
beforeMount (cb) { process.nextTick(cb || (() => {})) },
|
|
145
|
+
beforeUnmount (cb) { process.nextTick(cb || (() => {})) },
|
|
146
|
+
configure (cb) { process.nextTick(() => (cb || (() => {}))(installation.ready ? null : unavailable())) },
|
|
147
|
+
unconfigure (cb) { process.nextTick(() => (cb || (() => {}))(new Error('TAS never installs or removes macFUSE'))) },
|
|
148
|
+
isConfigured (cb) { process.nextTick(() => cb(null, installation.ready)) }
|
|
149
|
+
}
|
|
150
|
+
`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function patchFuseSource (fuseNativeDir) {
|
|
154
|
+
const sourcePath = path.join(fuseNativeDir, 'fuse-native.c')
|
|
155
|
+
const source = fs.readFileSync(sourcePath, 'utf8')
|
|
156
|
+
const oldDefine = '#define FUSE_USE_VERSION 29'
|
|
157
|
+
const newDefine = '#ifdef __APPLE__\n#define FUSE_USE_VERSION 26\n#else\n#define FUSE_USE_VERSION 29\n#endif'
|
|
158
|
+
|
|
159
|
+
if (source.includes(newDefine)) return
|
|
160
|
+
if (!source.includes(oldDefine)) throw new Error('Unsupported fuse-native source layout')
|
|
161
|
+
fs.writeFileSync(sourcePath, source.replace(oldDefine, newDefine))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function addLibraryRpath (addon, library) {
|
|
165
|
+
const result = childProcess.spawnSync(
|
|
166
|
+
'/usr/bin/install_name_tool',
|
|
167
|
+
['-add_rpath', path.dirname(library), addon],
|
|
168
|
+
{ stdio: 'ignore' }
|
|
169
|
+
)
|
|
170
|
+
// A duplicate rpath produces a nonzero exit code and is already safe.
|
|
171
|
+
return result.status === 0 || result.status === 1
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function installMacFuseAdapter ({
|
|
175
|
+
packageRoot = path.resolve(__dirname, '..', '..'),
|
|
176
|
+
platform = process.platform,
|
|
177
|
+
log = console
|
|
178
|
+
} = {}) {
|
|
179
|
+
if (platform !== 'darwin') return { skipped: true, reason: 'not macOS' }
|
|
180
|
+
|
|
181
|
+
const installation = findMacFuseInstallation()
|
|
182
|
+
if (!installation.ready) {
|
|
183
|
+
log.warn(`[tas] macOS FUSE was not built: ${installation.reason}`)
|
|
184
|
+
return { skipped: true, ...installation }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const fuseNativeDir = getFuseNativeDirectory(packageRoot)
|
|
188
|
+
const darwinLibraryDir = getDarwinLibraryDirectory(packageRoot)
|
|
189
|
+
if (!fuseNativeDir || !darwinLibraryDir) {
|
|
190
|
+
const reason = 'optional fuse-native dependencies are not installed'
|
|
191
|
+
log.warn(`[tas] macOS FUSE was not built: ${reason}`)
|
|
192
|
+
return { ready: false, reason }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
fs.writeFileSync(path.join(darwinLibraryDir, 'index.js'), macFuseAdapterSource())
|
|
197
|
+
patchFuseSource(fuseNativeDir)
|
|
198
|
+
|
|
199
|
+
const npmCli = process.env.npm_execpath
|
|
200
|
+
if (!npmCli) throw new Error('npm did not provide its rebuild executable')
|
|
201
|
+
|
|
202
|
+
const rebuild = childProcess.spawnSync(
|
|
203
|
+
process.execPath,
|
|
204
|
+
[npmCli, 'rebuild', 'fuse-native', '--build-from-source', '--foreground-scripts'],
|
|
205
|
+
{
|
|
206
|
+
cwd: packageRoot,
|
|
207
|
+
stdio: 'inherit',
|
|
208
|
+
env: { ...process.env, npm_config_build_from_source: 'true' }
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
if (rebuild.status !== 0) throw new Error('fuse-native could not compile against macFUSE')
|
|
212
|
+
|
|
213
|
+
const addon = path.join(fuseNativeDir, 'build', 'Release', 'fuse.node')
|
|
214
|
+
addLibraryRpath(addon, installation.library)
|
|
215
|
+
|
|
216
|
+
const status = validateMacFuseNativeBinding({ packageRoot })
|
|
217
|
+
if (!status.ready) throw new Error(status.reason)
|
|
218
|
+
log.log(`[tas] macFUSE native addon rebuilt for ${process.arch}`)
|
|
219
|
+
return status
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
222
|
+
log.warn(`[tas] macOS FUSE was not built: ${reason}`)
|
|
223
|
+
return { ready: false, reason }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
MACFUSE_BUNDLE,
|
|
229
|
+
findMacFuseInstallation,
|
|
230
|
+
getFuseNativeDirectory,
|
|
231
|
+
validateMacFuseNativeBinding,
|
|
232
|
+
installMacFuseAdapter
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (require.main === module) installMacFuseAdapter()
|