@nightowne/tas-cli 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/FAQ.md +33 -0
- package/QUICKSTART.md +47 -0
- package/README.md +356 -196
- package/package.json +11 -5
- package/src/cli.js +758 -213
- package/src/db/index.js +307 -24
- package/src/fuse/mount.js +336 -175
- package/src/index.js +187 -123
- package/src/manifest.js +104 -0
- package/src/share/server.js +7 -9
- package/src/sync/sync.js +105 -34
- package/src/telegram/client.js +10 -3
- package/src/telegram/pool.js +105 -0
- package/src/utils/branding.js +2 -2
- package/src/utils/chunker.js +4 -3
- package/src/utils/cli-helpers.js +90 -10
- package/src/utils/download-stream.js +10 -4
- package/src/utils/logical-path.js +44 -0
package/src/fuse/mount.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import fs from 'fs';
|
|
10
|
+
import os from 'os';
|
|
10
11
|
import { pipeline } from 'stream/promises';
|
|
11
12
|
|
|
12
13
|
let Fuse;
|
|
@@ -15,26 +16,93 @@ try {
|
|
|
15
16
|
} catch {
|
|
16
17
|
// fuse-native is optional — unavailable on ARM64 or systems without libfuse
|
|
17
18
|
}
|
|
18
|
-
import {
|
|
19
|
+
import { TelegramPool } from '../telegram/pool.js';
|
|
19
20
|
import { Encryptor } from '../crypto/encryption.js';
|
|
20
21
|
import { Compressor } from '../utils/compression.js';
|
|
21
22
|
import { FileIndex } from '../db/index.js';
|
|
22
|
-
import { createHeader } from '../utils/chunker.js';
|
|
23
23
|
import { createDownloadPipeline } from '../utils/download-stream.js';
|
|
24
|
+
import { processFile } from '../index.js';
|
|
25
|
+
import { hashFile } from '../crypto/encryption.js';
|
|
26
|
+
import { backupRemoteManifest } from '../manifest.js';
|
|
27
|
+
import {
|
|
28
|
+
normalizeLogicalPath,
|
|
29
|
+
listLogicalChildren,
|
|
30
|
+
isImplicitDirectory,
|
|
31
|
+
parentLogicalPath
|
|
32
|
+
} from '../utils/logical-path.js';
|
|
24
33
|
|
|
25
34
|
// File cache for performance (avoid re-downloading)
|
|
26
35
|
const fileCache = new Map();
|
|
27
36
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
28
37
|
const CACHE_MAX_ENTRIES = 100; // Prevent unbounded memory growth
|
|
29
38
|
|
|
39
|
+
function withTimeout(promise, ms, label) {
|
|
40
|
+
let timer;
|
|
41
|
+
const timeout = new Promise((_, reject) => {
|
|
42
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
43
|
+
});
|
|
44
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Verify the native FUSE stack, not merely that the JS module imports. */
|
|
48
|
+
export async function checkFuseRuntime() {
|
|
49
|
+
if (process.platform === 'darwin') {
|
|
50
|
+
return {
|
|
51
|
+
supported: false,
|
|
52
|
+
reason: 'macOS mount is unsupported: fuse-native 2.x targets obsolete OSXFUSE APIs and current macFUSE/Apple Silicon is not validated'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!Fuse) return { supported: false, reason: 'fuse-native is not installed or failed to load' };
|
|
56
|
+
|
|
57
|
+
const configured = await new Promise((resolve, reject) => {
|
|
58
|
+
Fuse.isConfigured((error, ready) => error ? reject(error) : resolve(ready));
|
|
59
|
+
});
|
|
60
|
+
if (!configured) return { supported: false, reason: 'FUSE kernel/userspace support is not configured' };
|
|
61
|
+
|
|
62
|
+
const mountPoint = fs.mkdtempSync(path.join(os.tmpdir(), 'tas-fuse-doctor-'));
|
|
63
|
+
const now = new Date();
|
|
64
|
+
const probe = new Fuse(mountPoint, {
|
|
65
|
+
getattr(filepath, cb) {
|
|
66
|
+
if (filepath === '/') return cb(0, { mode: 0o40755, size: 4096, mtime: now, atime: now, ctime: now });
|
|
67
|
+
if (filepath === '/probe') return cb(0, { mode: 0o100444, size: 0, mtime: now, atime: now, ctime: now });
|
|
68
|
+
return cb(Fuse.ENOENT);
|
|
69
|
+
},
|
|
70
|
+
readdir(filepath, cb) {
|
|
71
|
+
return filepath === '/' ? cb(0, ['probe']) : cb(Fuse.ENOENT);
|
|
72
|
+
},
|
|
73
|
+
open(filepath, flags, cb) { return cb(0, 1); },
|
|
74
|
+
read(filepath, fd, buffer, length, position, cb) { return cb(0); }
|
|
75
|
+
}, { force: true, mkdir: true });
|
|
76
|
+
|
|
77
|
+
let mounted = false;
|
|
78
|
+
try {
|
|
79
|
+
await withTimeout(new Promise((resolve, reject) => probe.mount(error => error ? reject(error) : resolve())), 10000, 'FUSE mount');
|
|
80
|
+
mounted = true;
|
|
81
|
+
const entries = await withTimeout(fs.promises.readdir(mountPoint), 10000, 'FUSE readdir');
|
|
82
|
+
if (!entries.includes('probe')) throw new Error('FUSE readdir smoke test returned unexpected entries');
|
|
83
|
+
await withTimeout(new Promise((resolve, reject) => probe.unmount(error => error ? reject(error) : resolve())), 10000, 'FUSE unmount');
|
|
84
|
+
mounted = false;
|
|
85
|
+
return { supported: true };
|
|
86
|
+
} finally {
|
|
87
|
+
if (mounted) {
|
|
88
|
+
try { await new Promise(resolve => probe.unmount(() => resolve())); } catch { }
|
|
89
|
+
}
|
|
90
|
+
try { fs.rmdirSync(mountPoint); } catch { }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
30
94
|
export class TelegramFS {
|
|
31
95
|
constructor(options) {
|
|
96
|
+
if (process.platform === 'darwin') {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'TAS mount is currently unsupported on macOS. fuse-native 2.x targets obsolete OSXFUSE APIs ' +
|
|
99
|
+
'and is not compatible with current macFUSE on Apple Silicon. Use push/pull/sync/share instead.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
32
102
|
if (!Fuse) {
|
|
33
103
|
throw new Error(
|
|
34
104
|
'fuse-native is not available on this system.\n' +
|
|
35
105
|
' On Linux x86_64: npm install fuse-native && sudo apt install fuse libfuse-dev\n' +
|
|
36
|
-
' On macOS: brew install macfuse && npm install fuse-native\n' +
|
|
37
|
-
' On ARM64: see https://github.com/ixchio/tas/issues/1 for a workaround\n' +
|
|
38
106
|
' All other TAS commands (push, pull, sync, share) work without FUSE.'
|
|
39
107
|
);
|
|
40
108
|
}
|
|
@@ -43,6 +111,7 @@ export class TelegramFS {
|
|
|
43
111
|
this.password = options.password;
|
|
44
112
|
this.config = options.config;
|
|
45
113
|
this.mountPoint = options.mountPoint;
|
|
114
|
+
this.backupManifest = options.backupManifest || backupRemoteManifest;
|
|
46
115
|
|
|
47
116
|
this.db = new FileIndex(path.join(this.dataDir, 'index.db'));
|
|
48
117
|
this.db.init();
|
|
@@ -52,25 +121,102 @@ export class TelegramFS {
|
|
|
52
121
|
this.client = null;
|
|
53
122
|
this.fuse = null;
|
|
54
123
|
|
|
55
|
-
// Pending writes
|
|
124
|
+
// Pending writes are disk-backed so a large FUSE write does not grow
|
|
125
|
+
// the Node process by the full file size.
|
|
56
126
|
this.writeBuffers = new Map();
|
|
127
|
+
this.virtualDirs = new Set(['']);
|
|
128
|
+
this.filePaths = new Set();
|
|
129
|
+
this.fileByLogicalPath = new Map();
|
|
130
|
+
this.implicitDirs = new Set(['']);
|
|
131
|
+
this.childrenByDir = new Map();
|
|
132
|
+
this._refreshPathIndex();
|
|
57
133
|
}
|
|
58
134
|
|
|
59
135
|
async initialize() {
|
|
60
136
|
// Connect to Telegram
|
|
61
|
-
this.client = new
|
|
62
|
-
|
|
63
|
-
|
|
137
|
+
this.client = new TelegramPool(this.dataDir, this.config.bots);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_logical(filepath, allowRoot = false) {
|
|
141
|
+
return normalizeLogicalPath(filepath, { allowRoot });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_allLogicalPaths() {
|
|
145
|
+
return [
|
|
146
|
+
...this.filePaths,
|
|
147
|
+
...this.writeBuffers.keys()
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_isDirectory(logicalPath) {
|
|
152
|
+
return this.virtualDirs.has(logicalPath) || this.implicitDirs.has(logicalPath) ||
|
|
153
|
+
isImplicitDirectory([...this.writeBuffers.keys()], logicalPath);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_refreshPathIndex() {
|
|
157
|
+
this.filePaths = new Set();
|
|
158
|
+
this.fileByLogicalPath = new Map();
|
|
159
|
+
this.implicitDirs = new Set(['']);
|
|
160
|
+
this.childrenByDir = new Map();
|
|
161
|
+
const addChild = (dir, child) => {
|
|
162
|
+
if (!this.childrenByDir.has(dir)) this.childrenByDir.set(dir, new Set());
|
|
163
|
+
this.childrenByDir.get(dir).add(child);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (const file of this.db.listAll()) {
|
|
167
|
+
const logical = normalizeLogicalPath(file.filename);
|
|
168
|
+
this.filePaths.add(logical);
|
|
169
|
+
if (!this.fileByLogicalPath.has(logical)) this.fileByLogicalPath.set(logical, file);
|
|
170
|
+
const parts = logical.split('/');
|
|
171
|
+
let dir = '';
|
|
172
|
+
for (let index = 0; index < parts.length; index++) {
|
|
173
|
+
addChild(dir, parts[index]);
|
|
174
|
+
if (index < parts.length - 1) {
|
|
175
|
+
dir = dir ? `${dir}/${parts[index]}` : parts[index];
|
|
176
|
+
this.implicitDirs.add(dir);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_file(logicalPath) {
|
|
183
|
+
return this.fileByLogicalPath.get(logicalPath);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_newWritePath(logicalPath) {
|
|
187
|
+
const dir = path.join(this.dataDir, 'fuse-writes');
|
|
188
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
189
|
+
const safe = Buffer.from(logicalPath).toString('hex').slice(0, 48) || 'root';
|
|
190
|
+
return path.join(dir, `${safe}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async _ensureWriteFile(logicalPath, { empty = false } = {}) {
|
|
194
|
+
if (this.writeBuffers.has(logicalPath)) return this.writeBuffers.get(logicalPath);
|
|
195
|
+
const tempPath = this._newWritePath(logicalPath);
|
|
196
|
+
if (empty) {
|
|
197
|
+
fs.closeSync(fs.openSync(tempPath, 'w', 0o600));
|
|
198
|
+
} else {
|
|
199
|
+
const existing = this._file(logicalPath);
|
|
200
|
+
if (existing) {
|
|
201
|
+
const cachedPath = await this.downloadFileToCache(logicalPath);
|
|
202
|
+
fs.copyFileSync(cachedPath, tempPath);
|
|
203
|
+
} else {
|
|
204
|
+
fs.closeSync(fs.openSync(tempPath, 'w', 0o600));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const entry = { path: tempPath, modified: true, isNew: !this._file(logicalPath) };
|
|
208
|
+
this.writeBuffers.set(logicalPath, entry);
|
|
209
|
+
return entry;
|
|
64
210
|
}
|
|
65
211
|
|
|
66
212
|
/**
|
|
67
213
|
* Get file attributes
|
|
68
214
|
*/
|
|
69
215
|
getattr(filepath, cb) {
|
|
70
|
-
|
|
216
|
+
let logical;
|
|
217
|
+
try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
|
|
71
218
|
|
|
72
|
-
|
|
73
|
-
if (filepath === '/') {
|
|
219
|
+
if (this._isDirectory(logical)) {
|
|
74
220
|
return cb(0, {
|
|
75
221
|
mtime: new Date(),
|
|
76
222
|
atime: new Date(),
|
|
@@ -83,13 +229,14 @@ export class TelegramFS {
|
|
|
83
229
|
}
|
|
84
230
|
|
|
85
231
|
// Check write buffers first (new/pending files)
|
|
86
|
-
const wb = this.writeBuffers.get(
|
|
232
|
+
const wb = this.writeBuffers.get(logical);
|
|
87
233
|
if (wb) {
|
|
234
|
+
const stats = fs.statSync(wb.path);
|
|
88
235
|
return cb(0, {
|
|
89
236
|
mtime: new Date(),
|
|
90
237
|
atime: new Date(),
|
|
91
238
|
ctime: new Date(),
|
|
92
|
-
size:
|
|
239
|
+
size: stats.size,
|
|
93
240
|
mode: 0o100644, // regular file
|
|
94
241
|
uid: process.getuid?.() || 0,
|
|
95
242
|
gid: process.getgid?.() || 0
|
|
@@ -97,7 +244,7 @@ export class TelegramFS {
|
|
|
97
244
|
}
|
|
98
245
|
|
|
99
246
|
// Look up file in index
|
|
100
|
-
const file = this.
|
|
247
|
+
const file = this._file(logical);
|
|
101
248
|
|
|
102
249
|
if (!file) {
|
|
103
250
|
return cb(Fuse.ENOENT);
|
|
@@ -118,28 +265,31 @@ export class TelegramFS {
|
|
|
118
265
|
* List directory contents
|
|
119
266
|
*/
|
|
120
267
|
readdir(filepath, cb) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
268
|
+
let logical;
|
|
269
|
+
try { logical = this._logical(filepath, true); } catch { return cb(Fuse.ENOENT); }
|
|
270
|
+
if (!this._isDirectory(logical)) return cb(Fuse.ENOENT);
|
|
124
271
|
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
272
|
+
const names = new Set(this.childrenByDir.get(logical) || []);
|
|
273
|
+
for (const name of listLogicalChildren([...this.writeBuffers.keys(), ...this.virtualDirs].filter(Boolean), logical)) {
|
|
274
|
+
names.add(name);
|
|
275
|
+
}
|
|
276
|
+
return cb(0, [...names].sort((a, b) => a.localeCompare(b)));
|
|
129
277
|
}
|
|
130
278
|
|
|
131
279
|
/**
|
|
132
280
|
* Open a file (just validates it exists)
|
|
133
281
|
*/
|
|
134
282
|
open(filepath, flags, cb) {
|
|
135
|
-
|
|
283
|
+
let logical;
|
|
284
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
285
|
+
if (this._isDirectory(logical)) return cb(Fuse.EISDIR || Fuse.EINVAL);
|
|
136
286
|
|
|
137
287
|
// Check if it's a new file being written
|
|
138
|
-
if (this.writeBuffers.has(
|
|
288
|
+
if (this.writeBuffers.has(logical)) {
|
|
139
289
|
return cb(0, 42); // Return a dummy fd
|
|
140
290
|
}
|
|
141
291
|
|
|
142
|
-
const file = this.
|
|
292
|
+
const file = this._file(logical);
|
|
143
293
|
|
|
144
294
|
if (!file) {
|
|
145
295
|
return cb(Fuse.ENOENT);
|
|
@@ -152,24 +302,26 @@ export class TelegramFS {
|
|
|
152
302
|
* Read file contents from disk cache
|
|
153
303
|
*/
|
|
154
304
|
async read(filepath, fd, buffer, length, position, cb) {
|
|
155
|
-
|
|
305
|
+
let logical;
|
|
306
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
156
307
|
|
|
157
308
|
try {
|
|
158
309
|
// Check write buffers first
|
|
159
|
-
const wb = this.writeBuffers.get(
|
|
310
|
+
const wb = this.writeBuffers.get(logical);
|
|
160
311
|
if (wb) {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
312
|
+
const writeFd = fs.openSync(wb.path, 'r');
|
|
313
|
+
const bytesRead = fs.readSync(writeFd, buffer, 0, length, position);
|
|
314
|
+
fs.closeSync(writeFd);
|
|
315
|
+
return cb(bytesRead);
|
|
164
316
|
}
|
|
165
317
|
|
|
166
318
|
// Check cache first
|
|
167
|
-
let cachedPath = this.getCached(
|
|
319
|
+
let cachedPath = this.getCached(logical);
|
|
168
320
|
|
|
169
321
|
if (!cachedPath) {
|
|
170
322
|
// Download, decrypt, and save to disk cache
|
|
171
|
-
cachedPath = await this.downloadFileToCache(
|
|
172
|
-
this.setCache(
|
|
323
|
+
cachedPath = await this.downloadFileToCache(logical);
|
|
324
|
+
this.setCache(logical, cachedPath);
|
|
173
325
|
}
|
|
174
326
|
|
|
175
327
|
// Copy requested portion to buffer from disk
|
|
@@ -187,50 +339,32 @@ export class TelegramFS {
|
|
|
187
339
|
/**
|
|
188
340
|
* Write to a file (buffers until release)
|
|
189
341
|
*/
|
|
190
|
-
write(filepath, fd, buffer, length, position, cb) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// Expand buffer if needed
|
|
204
|
-
const newSize = Math.max(wb.data.length, position + length);
|
|
205
|
-
if (newSize > wb.data.length) {
|
|
206
|
-
const newBuf = Buffer.alloc(newSize);
|
|
207
|
-
wb.data.copy(newBuf);
|
|
208
|
-
wb.data = newBuf;
|
|
342
|
+
async write(filepath, fd, buffer, length, position, cb) {
|
|
343
|
+
let logical;
|
|
344
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
345
|
+
try {
|
|
346
|
+
const wb = await this._ensureWriteFile(logical);
|
|
347
|
+
const writeFd = fs.openSync(wb.path, 'r+');
|
|
348
|
+
fs.writeSync(writeFd, buffer, 0, length, position);
|
|
349
|
+
fs.closeSync(writeFd);
|
|
350
|
+
wb.modified = true;
|
|
351
|
+
return cb(length);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
console.error('Write error:', error.message);
|
|
354
|
+
return cb(Fuse.EIO);
|
|
209
355
|
}
|
|
210
|
-
|
|
211
|
-
// Copy incoming data
|
|
212
|
-
buffer.copy(wb.data, position, 0, length);
|
|
213
|
-
wb.modified = true;
|
|
214
|
-
|
|
215
|
-
return cb(length);
|
|
216
356
|
}
|
|
217
357
|
|
|
218
358
|
/**
|
|
219
359
|
* Create a new file
|
|
220
360
|
*/
|
|
221
361
|
create(filepath, mode, cb) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
console.log(`[FUSE] Creating file: ${
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
data: Buffer.alloc(0),
|
|
229
|
-
modified: true,
|
|
230
|
-
isNew: true
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
return cb(0, 42); // Return a valid fd
|
|
362
|
+
let logical;
|
|
363
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
|
|
364
|
+
console.log(`[FUSE] Creating file: ${logical}`);
|
|
365
|
+
this._ensureWriteFile(logical, { empty: true })
|
|
366
|
+
.then(() => cb(0, 42))
|
|
367
|
+
.catch(() => cb(Fuse.EIO));
|
|
234
368
|
}
|
|
235
369
|
|
|
236
370
|
/**
|
|
@@ -244,22 +378,24 @@ export class TelegramFS {
|
|
|
244
378
|
* Flush/sync file to Telegram
|
|
245
379
|
*/
|
|
246
380
|
async release(filepath, fd, cb) {
|
|
247
|
-
|
|
381
|
+
let logical;
|
|
382
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
248
383
|
|
|
249
|
-
const wb = this.writeBuffers.get(
|
|
384
|
+
const wb = this.writeBuffers.get(logical);
|
|
250
385
|
if (!wb || !wb.modified) {
|
|
251
386
|
return cb(0);
|
|
252
387
|
}
|
|
253
388
|
|
|
254
389
|
try {
|
|
255
390
|
// Upload to Telegram
|
|
256
|
-
await this.uploadFile(
|
|
391
|
+
await this.uploadFile(logical, wb.path);
|
|
257
392
|
|
|
258
393
|
// Clear write buffer
|
|
259
|
-
this.writeBuffers.delete(
|
|
394
|
+
this.writeBuffers.delete(logical);
|
|
395
|
+
try { fs.unlinkSync(wb.path); } catch { }
|
|
260
396
|
|
|
261
397
|
// Invalidate cache
|
|
262
|
-
this.invalidateCache(
|
|
398
|
+
this.invalidateCache(logical);
|
|
263
399
|
|
|
264
400
|
return cb(0);
|
|
265
401
|
} catch (err) {
|
|
@@ -272,25 +408,40 @@ export class TelegramFS {
|
|
|
272
408
|
* Delete a file
|
|
273
409
|
*/
|
|
274
410
|
async unlink(filepath, cb) {
|
|
275
|
-
|
|
276
|
-
|
|
411
|
+
let logical;
|
|
412
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
413
|
+
const file = this._file(logical);
|
|
277
414
|
|
|
278
415
|
if (!file) {
|
|
279
416
|
return cb(Fuse.ENOENT);
|
|
280
417
|
}
|
|
281
418
|
|
|
282
419
|
try {
|
|
283
|
-
// Delete from Telegram (optional - could just remove from index)
|
|
284
420
|
const chunks = this.db.getChunks(file.id);
|
|
285
|
-
|
|
286
|
-
|
|
421
|
+
const before = this.db.exportManifest({ includeShares: true });
|
|
422
|
+
this.db.delete(file.id);
|
|
423
|
+
this._refreshPathIndex();
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
await this.backupManifest({
|
|
427
|
+
dataDir: this.dataDir,
|
|
428
|
+
password: this.password,
|
|
429
|
+
config: this.config,
|
|
430
|
+
telegramPool: this.client
|
|
431
|
+
});
|
|
432
|
+
} catch (error) {
|
|
433
|
+
this.db.importManifest(before);
|
|
434
|
+
this._refreshPathIndex();
|
|
435
|
+
throw error;
|
|
287
436
|
}
|
|
288
437
|
|
|
289
|
-
//
|
|
290
|
-
|
|
438
|
+
// Delete remote messages only after the new recovery point is durable.
|
|
439
|
+
for (const chunk of chunks) {
|
|
440
|
+
await this.client.deleteMessage(chunk.message_id, chunk.bot_id || null);
|
|
441
|
+
}
|
|
291
442
|
|
|
292
443
|
// Invalidate cache
|
|
293
|
-
this.invalidateCache(
|
|
444
|
+
this.invalidateCache(logical);
|
|
294
445
|
|
|
295
446
|
return cb(0);
|
|
296
447
|
} catch (err) {
|
|
@@ -302,18 +453,61 @@ export class TelegramFS {
|
|
|
302
453
|
/**
|
|
303
454
|
* Rename/move a file (just update index, data stays in Telegram)
|
|
304
455
|
*/
|
|
305
|
-
rename(src, dest, cb) {
|
|
306
|
-
|
|
307
|
-
|
|
456
|
+
async rename(src, dest, cb) {
|
|
457
|
+
let oldName;
|
|
458
|
+
let newName;
|
|
459
|
+
try {
|
|
460
|
+
oldName = this._logical(src);
|
|
461
|
+
newName = this._logical(dest);
|
|
462
|
+
} catch {
|
|
463
|
+
return cb(Fuse.EINVAL);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (oldName === newName) return cb(0);
|
|
308
467
|
|
|
309
|
-
const
|
|
468
|
+
const pendingWrite = this.writeBuffers.get(oldName);
|
|
469
|
+
if (pendingWrite) {
|
|
470
|
+
this.writeBuffers.delete(oldName);
|
|
471
|
+
this.writeBuffers.set(newName, pendingWrite);
|
|
472
|
+
return cb(0);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const file = this._file(oldName);
|
|
310
476
|
if (!file) {
|
|
311
477
|
return cb(Fuse.ENOENT);
|
|
312
478
|
}
|
|
313
479
|
|
|
480
|
+
// Avoid duplicate filenames: remove the destination first
|
|
481
|
+
try {
|
|
482
|
+
const destFile = this._file(newName);
|
|
483
|
+
if (destFile && destFile.id !== file.id) {
|
|
484
|
+
const destChunks = this.db.getChunks(destFile.id);
|
|
485
|
+
for (const chunk of destChunks) {
|
|
486
|
+
try { await this.client.deleteMessage(chunk.message_id, chunk.bot_id || null); } catch (e) { }
|
|
487
|
+
}
|
|
488
|
+
this.db.deleteFileCascade(destFile.id);
|
|
489
|
+
this.invalidateCache(newName);
|
|
490
|
+
}
|
|
491
|
+
} catch (e) { /* best effort */ }
|
|
492
|
+
|
|
314
493
|
// Update filename in database
|
|
315
494
|
this.db.db.prepare('UPDATE files SET filename = ? WHERE id = ?')
|
|
316
495
|
.run(newName, file.id);
|
|
496
|
+
this._refreshPathIndex();
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
await this.backupManifest({
|
|
500
|
+
dataDir: this.dataDir,
|
|
501
|
+
password: this.password,
|
|
502
|
+
config: this.config,
|
|
503
|
+
telegramPool: this.client
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
console.error('Remote manifest update failed after rename:', error.message);
|
|
507
|
+
this.db.db.prepare('UPDATE files SET filename = ? WHERE id = ?').run(oldName, file.id);
|
|
508
|
+
this._refreshPathIndex();
|
|
509
|
+
return cb(Fuse.EIO);
|
|
510
|
+
}
|
|
317
511
|
|
|
318
512
|
// Update cache key
|
|
319
513
|
const cached = fileCache.get(oldName);
|
|
@@ -325,46 +519,50 @@ export class TelegramFS {
|
|
|
325
519
|
return cb(0);
|
|
326
520
|
}
|
|
327
521
|
|
|
522
|
+
mkdir(filepath, mode, cb) {
|
|
523
|
+
let logical;
|
|
524
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
|
|
525
|
+
if (this._file(logical) || this._isDirectory(logical)) return cb(Fuse.EEXIST || Fuse.EINVAL);
|
|
526
|
+
const parent = parentLogicalPath(logical);
|
|
527
|
+
if (!this._isDirectory(parent)) return cb(Fuse.ENOENT);
|
|
528
|
+
this.virtualDirs.add(logical);
|
|
529
|
+
return cb(0);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
rmdir(filepath, cb) {
|
|
533
|
+
let logical;
|
|
534
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.EINVAL); }
|
|
535
|
+
if (!this._isDirectory(logical)) return cb(Fuse.ENOENT);
|
|
536
|
+
if (listLogicalChildren([...this._allLogicalPaths(), ...this.virtualDirs].filter(Boolean), logical).length > 0) {
|
|
537
|
+
return cb(Fuse.ENOTEMPTY || Fuse.EINVAL);
|
|
538
|
+
}
|
|
539
|
+
this.virtualDirs.delete(logical);
|
|
540
|
+
return cb(0);
|
|
541
|
+
}
|
|
542
|
+
|
|
328
543
|
/**
|
|
329
544
|
* Truncate a file
|
|
330
545
|
*/
|
|
331
|
-
truncate(filepath, size, cb) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
data: Buffer.alloc(0),
|
|
345
|
-
modified: true
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const wb = this.writeBuffers.get(filename);
|
|
351
|
-
|
|
352
|
-
if (size < wb.data.length) {
|
|
353
|
-
wb.data = wb.data.subarray(0, size);
|
|
354
|
-
} else if (size > wb.data.length) {
|
|
355
|
-
const newBuf = Buffer.alloc(size);
|
|
356
|
-
wb.data.copy(newBuf);
|
|
357
|
-
wb.data = newBuf;
|
|
546
|
+
async truncate(filepath, size, cb) {
|
|
547
|
+
let logical;
|
|
548
|
+
try { logical = this._logical(filepath); } catch { return cb(Fuse.ENOENT); }
|
|
549
|
+
try {
|
|
550
|
+
// Always hydrate a remote file before truncating it. Falling back
|
|
551
|
+
// to an empty buffer silently destroyed uncached content.
|
|
552
|
+
const wb = await this._ensureWriteFile(logical);
|
|
553
|
+
fs.truncateSync(wb.path, size);
|
|
554
|
+
wb.modified = true;
|
|
555
|
+
return cb(0);
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error('Truncate error:', error.message);
|
|
558
|
+
return cb(Fuse.EIO);
|
|
358
559
|
}
|
|
359
|
-
|
|
360
|
-
wb.modified = true;
|
|
361
|
-
return cb(0);
|
|
362
560
|
}
|
|
363
561
|
|
|
364
562
|
// ============== Helper Methods ==============
|
|
365
563
|
|
|
366
564
|
async downloadFileToCache(filename) {
|
|
367
|
-
const file = this.
|
|
565
|
+
const file = this._file(filename);
|
|
368
566
|
if (!file) throw new Error('File not found');
|
|
369
567
|
|
|
370
568
|
const cacheDir = path.join(this.dataDir, 'cache');
|
|
@@ -402,68 +600,29 @@ export class TelegramFS {
|
|
|
402
600
|
return outputPath;
|
|
403
601
|
}
|
|
404
602
|
|
|
405
|
-
async uploadFile(filename,
|
|
406
|
-
const
|
|
407
|
-
const hash =
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
try { await this.client.deleteMessage(chunk.message_id); } catch (e) { }
|
|
416
|
-
}
|
|
417
|
-
this.db.delete(existingByName.id);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Check if already exists by hash (same content, different name)
|
|
421
|
-
const existingByHash = this.db.findByHash(hash);
|
|
422
|
-
if (existingByHash) {
|
|
423
|
-
// Same content already exists, just skip
|
|
424
|
-
console.log(`[FUSE] File with same content already exists as ${existingByHash.filename}`);
|
|
603
|
+
async uploadFile(filename, sourcePath) {
|
|
604
|
+
const existing = this._file(filename);
|
|
605
|
+
const hash = await hashFile(sourcePath);
|
|
606
|
+
if (existing?.hash === hash) {
|
|
607
|
+
await this.backupManifest({
|
|
608
|
+
dataDir: this.dataDir,
|
|
609
|
+
password: this.password,
|
|
610
|
+
config: this.config,
|
|
611
|
+
telegramPool: this.client
|
|
612
|
+
});
|
|
425
613
|
return;
|
|
426
614
|
}
|
|
427
615
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const tempDir = process.env.TAS_TMP_DIR || path.join(this.dataDir, 'tmp');
|
|
436
|
-
if (!fs.existsSync(tempDir)) {
|
|
437
|
-
fs.mkdirSync(tempDir, { recursive: true });
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const flags = compressed ? 1 : 0;
|
|
441
|
-
const header = createHeader(filename, data.length, 0, 1, flags);
|
|
442
|
-
const fileData = Buffer.concat([header, encryptedData]);
|
|
443
|
-
|
|
444
|
-
const tempPath = path.join(tempDir, `${hash.substring(0, 12)}.tas`);
|
|
445
|
-
fs.writeFileSync(tempPath, fileData);
|
|
446
|
-
|
|
447
|
-
// Upload to Telegram
|
|
448
|
-
const result = await this.client.sendFile(tempPath, `📦 ${filename}`);
|
|
449
|
-
|
|
450
|
-
// Add to index
|
|
451
|
-
const fileId = this.db.addFile({
|
|
452
|
-
filename,
|
|
453
|
-
hash,
|
|
454
|
-
originalSize: data.length,
|
|
455
|
-
storedSize: encryptedData.length,
|
|
456
|
-
chunks: 1,
|
|
457
|
-
compressed
|
|
616
|
+
const result = await processFile(sourcePath, {
|
|
617
|
+
password: this.password,
|
|
618
|
+
dataDir: this.dataDir,
|
|
619
|
+
customName: filename,
|
|
620
|
+
config: this.config,
|
|
621
|
+
telegramPool: this.client,
|
|
622
|
+
replaceExisting: true
|
|
458
623
|
});
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
this.db.db.prepare('UPDATE chunks SET file_telegram_id = ? WHERE file_id = ? AND chunk_index = ?')
|
|
462
|
-
.run(result.fileId, fileId, 0);
|
|
463
|
-
|
|
464
|
-
// Cleanup
|
|
465
|
-
fs.unlinkSync(tempPath);
|
|
466
|
-
try { fs.rmdirSync(tempDir); } catch (e) { }
|
|
624
|
+
this._refreshPathIndex();
|
|
625
|
+
if (result.manifestWarning) throw new Error(`Remote recovery manifest failed: ${result.manifestWarning}`);
|
|
467
626
|
}
|
|
468
627
|
|
|
469
628
|
getCached(filename) {
|
|
@@ -537,6 +696,8 @@ export class TelegramFS {
|
|
|
537
696
|
release: this.release.bind(this),
|
|
538
697
|
unlink: this.unlink.bind(this),
|
|
539
698
|
rename: this.rename.bind(this),
|
|
699
|
+
mkdir: this.mkdir.bind(this),
|
|
700
|
+
rmdir: this.rmdir.bind(this),
|
|
540
701
|
truncate: this.truncate.bind(this),
|
|
541
702
|
ftruncate: this.ftruncate.bind(this)
|
|
542
703
|
};
|