@filebox/ftp-server 1.0.0 → 1.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/dist/index.cjs ADDED
@@ -0,0 +1,965 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/index.ts
30
+ var index_exports = {};
31
+ __export(index_exports, {
32
+ FileBoxFtpFileSystem: () => FileBoxFtpFileSystem,
33
+ FileBoxFtpServer: () => FileBoxFtpServer,
34
+ createFileBoxFtpSessionFactory: () => createFileBoxFtpSessionFactory,
35
+ createRuntime: () => createRuntime,
36
+ createStaticFtpAuthenticator: () => createStaticFtpAuthenticator,
37
+ createWorkspaceRuntime: () => createWorkspaceRuntime,
38
+ normalizeServerCredentials: () => normalizeServerCredentials
39
+ });
40
+ module.exports = __toCommonJS(index_exports);
41
+
42
+ // src/server/index.ts
43
+ var import_ftp_srv = require("ftp-srv");
44
+
45
+ // src/server/auth.ts
46
+ function normalizeServerCredentials(options) {
47
+ return {
48
+ username: options.username || "filebox",
49
+ password: options.password || "filebox",
50
+ anonymous: options.anonymous ?? false
51
+ };
52
+ }
53
+ function createStaticFtpAuthenticator(credentials) {
54
+ return ({ username, password }) => {
55
+ if (credentials.anonymous) {
56
+ return true;
57
+ }
58
+ return username === credentials.username && password === credentials.password;
59
+ };
60
+ }
61
+
62
+ // src/server/index.ts
63
+ function normalizeServerOptions(options = {}) {
64
+ const credentials = normalizeServerCredentials(options);
65
+ return {
66
+ url: options.url || "ftp://0.0.0.0:2121",
67
+ username: credentials.username,
68
+ password: credentials.password,
69
+ anonymous: credentials.anonymous,
70
+ passiveUrl: options.passiveUrl ?? null,
71
+ passivePortMin: options.passivePortMin ?? 3e4,
72
+ passivePortMax: options.passivePortMax ?? 30999,
73
+ timeout: options.timeout ?? 0,
74
+ greeting: options.greeting ?? null,
75
+ mountedDriveNames: options.mountedDriveNames ?? [],
76
+ loginLabel: options.loginLabel ?? null,
77
+ authenticate: options.authenticate,
78
+ createSession: options.createSession
79
+ };
80
+ }
81
+ var FileBoxFtpServer = class {
82
+ options;
83
+ authenticate;
84
+ ftpServer = null;
85
+ constructor(options = {}) {
86
+ this.options = normalizeServerOptions(options);
87
+ if (typeof this.options.createSession !== "function") {
88
+ throw new Error("FileBoxFtpServer requires a createSession handler.");
89
+ }
90
+ this.authenticate = this.options.authenticate || createStaticFtpAuthenticator({
91
+ anonymous: this.options.anonymous,
92
+ username: this.options.username,
93
+ password: this.options.password
94
+ });
95
+ }
96
+ async init() {
97
+ if (this.ftpServer) {
98
+ return this;
99
+ }
100
+ this.ftpServer = new import_ftp_srv.FtpSrv({
101
+ url: this.options.url,
102
+ anonymous: this.options.anonymous,
103
+ pasv_url: this.options.passiveUrl || void 0,
104
+ pasv_min: this.options.passivePortMin,
105
+ pasv_max: this.options.passivePortMax,
106
+ timeout: this.options.timeout,
107
+ greeting: this.options.greeting || void 0
108
+ });
109
+ this.ftpServer.on("login", ({ username, password }, resolve, reject) => {
110
+ void this.handleLogin({ username, password }, resolve, reject);
111
+ });
112
+ return this;
113
+ }
114
+ async handleLogin(credentials, resolve, reject) {
115
+ try {
116
+ const loginContext = {
117
+ username: credentials.username,
118
+ password: credentials.password,
119
+ anonymous: this.options.anonymous
120
+ };
121
+ const authenticated = await this.authenticate(loginContext);
122
+ if (!authenticated) {
123
+ reject(new Error("Invalid username or password"));
124
+ return;
125
+ }
126
+ const createSession = this.options.createSession;
127
+ if (!createSession) {
128
+ reject(new Error("FTP session factory is not configured."));
129
+ return;
130
+ }
131
+ const session = await createSession(loginContext);
132
+ if (!session?.fs) {
133
+ reject(new Error("FTP session must provide a file system instance."));
134
+ return;
135
+ }
136
+ resolve({
137
+ root: session.root || "/",
138
+ fs: session.fs
139
+ });
140
+ } catch (error) {
141
+ reject(error instanceof Error ? error : new Error(String(error)));
142
+ }
143
+ }
144
+ async listen() {
145
+ await this.init();
146
+ await this.ftpServer.listen();
147
+ return this;
148
+ }
149
+ async close() {
150
+ if (this.ftpServer) {
151
+ const server = this.ftpServer;
152
+ this.ftpServer = null;
153
+ await server.close();
154
+ }
155
+ }
156
+ getFtpServer() {
157
+ if (!this.ftpServer) {
158
+ throw new Error("FTP server is not initialized. Call init() first.");
159
+ }
160
+ return this.ftpServer;
161
+ }
162
+ getSummary() {
163
+ const loginLabel = this.options.loginLabel || (this.options.anonymous ? "anonymous" : this.options.username);
164
+ return {
165
+ url: this.options.url,
166
+ mountedDriveNames: this.options.mountedDriveNames,
167
+ passiveUrl: this.options.passiveUrl,
168
+ passivePortMin: this.options.passivePortMin,
169
+ passivePortMax: this.options.passivePortMax,
170
+ anonymous: this.options.anonymous,
171
+ username: this.options.username,
172
+ loginLabel,
173
+ timeout: this.options.timeout
174
+ };
175
+ }
176
+ };
177
+
178
+ // src/filesystem/index.ts
179
+ var import_node_fs = __toESM(require("node:fs"), 1);
180
+ var import_node_path2 = __toESM(require("node:path"), 1);
181
+ var import_node_crypto = __toESM(require("node:crypto"), 1);
182
+ var import_node_stream = require("node:stream");
183
+ var import_node_url = require("node:url");
184
+ var import_ftp_srv2 = require("ftp-srv");
185
+ var import_runtime = require("@filebox/runtime");
186
+
187
+ // src/filesystem/path.ts
188
+ var import_node_path = __toESM(require("node:path"), 1);
189
+ function normalizeFtpClientPath(targetPath) {
190
+ const normalizedInput = (targetPath || "/").trim();
191
+ if (normalizedInput === "" || normalizedInput === "." || normalizedInput === "./") {
192
+ return "/";
193
+ }
194
+ const normalized = import_node_path.default.posix.normalize(normalizedInput);
195
+ if (normalized === "." || normalized === "/.") {
196
+ return "/";
197
+ }
198
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
199
+ }
200
+
201
+ // src/filesystem/index.ts
202
+ function asDate(input) {
203
+ const date = input ? new Date(String(input)) : /* @__PURE__ */ new Date(0);
204
+ return Number.isNaN(date.getTime()) ? /* @__PURE__ */ new Date(0) : date;
205
+ }
206
+ function toFileSystemError(error) {
207
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "File system error";
208
+ return new Error(message);
209
+ }
210
+ function toFileEntry(stat) {
211
+ const isDirectory = stat?.type === "folder" || stat?.directory === true;
212
+ const byteSize = typeof stat?.byte === "number" ? stat.byte : typeof stat?.size === "number" ? stat.size : 0;
213
+ return {
214
+ name: stat?.name || "",
215
+ size: Number.isFinite(byteSize) ? byteSize : 0,
216
+ atime: asDate(stat?.atime),
217
+ mtime: asDate(stat?.mtime),
218
+ ctime: asDate(stat?.ctime),
219
+ birthtime: asDate(stat?.ctime),
220
+ isDirectory: () => isDirectory,
221
+ isFile: () => !isDirectory
222
+ };
223
+ }
224
+ function createUploadFileMetadata(tempFilePath, fileName, workspaceRoot) {
225
+ const stats = import_node_fs.default.statSync(tempFilePath);
226
+ const parsedPath = import_node_path2.default.parse(tempFilePath);
227
+ const relativeBase = workspaceRoot || import_node_path2.default.dirname(tempFilePath);
228
+ return {
229
+ path: parsedPath.dir,
230
+ name: fileName,
231
+ baseName: import_node_path2.default.parse(fileName).name,
232
+ ext: import_node_path2.default.parse(fileName).ext,
233
+ size: stats.size,
234
+ absolutePath: tempFilePath,
235
+ relativePath: import_node_path2.default.relative(relativeBase, tempFilePath),
236
+ mimeType: "",
237
+ lastModified: stats.mtime,
238
+ parentPath: parsedPath.dir,
239
+ isFile: true,
240
+ isDirectory: false,
241
+ md5: async () => calculateMd5(tempFilePath)
242
+ };
243
+ }
244
+ function calculateMd5(filePath) {
245
+ return new Promise((resolve, reject) => {
246
+ const hash = import_node_crypto.default.createHash("md5");
247
+ const input = import_node_fs.default.createReadStream(filePath);
248
+ input.on("readable", () => {
249
+ const data = input.read();
250
+ if (data) {
251
+ hash.update(data);
252
+ return;
253
+ }
254
+ resolve(hash.digest("hex"));
255
+ });
256
+ input.on("error", reject);
257
+ });
258
+ }
259
+ function resolveReadableDownloadPath(rawUrl) {
260
+ if (rawUrl.startsWith("file://")) {
261
+ return (0, import_node_url.fileURLToPath)(rawUrl);
262
+ }
263
+ if (import_node_fs.default.existsSync(rawUrl)) {
264
+ return rawUrl;
265
+ }
266
+ return null;
267
+ }
268
+ function parseRemoteResponseTotalSize(response, fallback) {
269
+ const contentRange = response.headers.get("content-range");
270
+ if (contentRange) {
271
+ const match = contentRange.match(/\/(\d+)$/);
272
+ if (match) {
273
+ return Number.parseInt(match[1], 10);
274
+ }
275
+ }
276
+ const contentLength = response.headers.get("content-length");
277
+ if (contentLength) {
278
+ const size = Number.parseInt(contentLength, 10);
279
+ if (Number.isFinite(size)) {
280
+ return size;
281
+ }
282
+ }
283
+ return typeof fallback === "number" && Number.isFinite(fallback) ? fallback : 0;
284
+ }
285
+ function parseDownloadEncryptionProfile(rawUrl) {
286
+ try {
287
+ return (0, import_runtime.parseEncryptionQueryParams)(new URL(rawUrl));
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+ function resolveFtpClientReference(currentDirectory, targetPath = ".") {
293
+ const resolvedPath = targetPath.replace(/\\/g, "/");
294
+ const clientPath = import_node_path2.default.posix.isAbsolute(resolvedPath) ? import_node_path2.default.posix.normalize(resolvedPath) : import_node_path2.default.posix.join("/", currentDirectory, resolvedPath);
295
+ return normalizeFtpClientPath(clientPath);
296
+ }
297
+ function resolveFtpDownloadDecryption(input) {
298
+ const queryProfile = parseDownloadEncryptionProfile(
299
+ input.downloadInfo.url || ""
300
+ );
301
+ const encryptedByName = (0, import_runtime.isContentEncryptedName)(
302
+ import_node_path2.default.posix.basename(input.clientPath)
303
+ );
304
+ if (!encryptedByName && !queryProfile) {
305
+ return null;
306
+ }
307
+ const profile = (0, import_runtime.resolveEncryptionProfile)({
308
+ password: input.config.encryption?.password,
309
+ salt: input.config.encryption?.salt,
310
+ algorithm: queryProfile?.algorithm || input.config.encryption?.advanced?.algorithm,
311
+ partialEncryption: queryProfile?.partialEncryption || input.config.encryption?.advanced?.partialEncryption
312
+ });
313
+ if (!profile.password) {
314
+ throw new Error(`Missing encryption password for ${input.clientPath}`);
315
+ }
316
+ return profile;
317
+ }
318
+ var DownloadDecryptTransform = class extends import_node_stream.Transform {
319
+ constructor(encryptor, startOffset, encryptedBytes) {
320
+ super();
321
+ this.encryptor = encryptor;
322
+ this.encryptedBytes = encryptedBytes;
323
+ this.offset = startOffset;
324
+ }
325
+ offset;
326
+ async _transform(chunk, _encoding, callback) {
327
+ try {
328
+ const chunkStart = this.offset;
329
+ const chunkEnd = chunkStart + chunk.length;
330
+ if (chunkStart >= this.encryptedBytes) {
331
+ this.offset += chunk.length;
332
+ callback(null, chunk);
333
+ return;
334
+ }
335
+ if (chunkEnd <= this.encryptedBytes) {
336
+ const decrypted2 = await this.encryptor.decryptChunk(
337
+ new Uint8Array(chunk),
338
+ this.offset
339
+ );
340
+ this.offset += chunk.length;
341
+ callback(null, Buffer.from(decrypted2));
342
+ return;
343
+ }
344
+ const encryptedPartLength = this.encryptedBytes - chunkStart;
345
+ const encryptedPart = chunk.slice(0, encryptedPartLength);
346
+ const plainPart = chunk.slice(encryptedPartLength);
347
+ const decrypted = await this.encryptor.decryptChunk(
348
+ new Uint8Array(encryptedPart),
349
+ this.offset
350
+ );
351
+ this.offset += chunk.length;
352
+ callback(null, Buffer.concat([Buffer.from(decrypted), plainPart]));
353
+ } catch (error) {
354
+ callback(toFileSystemError(error));
355
+ }
356
+ }
357
+ };
358
+ function isUploadHandle(value) {
359
+ return Boolean(
360
+ value && typeof value === "object" && "upload" in value && typeof value.upload === "function"
361
+ );
362
+ }
363
+ async function syncUploadedFileCache(volume, parentPath, fileName, uploadResult) {
364
+ const cache = volume?._fs?.cache;
365
+ const fsAdapter = volume?._fs;
366
+ if (!cache || !fsAdapter) {
367
+ return;
368
+ }
369
+ const targetPath = normalizeFtpClientPath(
370
+ import_node_path2.default.posix.join(parentPath, fileName)
371
+ );
372
+ try {
373
+ if (uploadResult) {
374
+ await cache.handleOperation({
375
+ type: "create",
376
+ path: targetPath,
377
+ parentPath,
378
+ newItem: uploadResult,
379
+ fs: fsAdapter
380
+ });
381
+ return;
382
+ }
383
+ } catch {
384
+ }
385
+ if (typeof cache.markForRefresh === "function") {
386
+ await cache.markForRefresh(parentPath);
387
+ await cache.markForRefresh(targetPath);
388
+ return;
389
+ }
390
+ if (typeof volume.refresh === "function") {
391
+ await volume.refresh(parentPath);
392
+ await volume.refresh(targetPath);
393
+ }
394
+ }
395
+ var DeferredUploadWriteStream = class extends import_node_stream.Writable {
396
+ constructor(volume, relativePath, tempFilePath, runtime) {
397
+ super();
398
+ this.volume = volume;
399
+ this.relativePath = relativePath;
400
+ this.tempFilePath = tempFilePath;
401
+ this.runtime = runtime;
402
+ this.localStream = import_node_fs.default.createWriteStream(this.tempFilePath, { flags: "w" });
403
+ }
404
+ localStream;
405
+ commitPromise = null;
406
+ tempFileRemoved = false;
407
+ _write(chunk, encoding, callback) {
408
+ this.localStream.write(chunk, encoding, callback);
409
+ }
410
+ _final(callback) {
411
+ this.localStream.end(() => {
412
+ this.commitPromise = this.commitUploadedFile();
413
+ this.commitPromise.then(() => callback()).catch((error) => callback(toFileSystemError(error))).finally(() => {
414
+ this.cleanupTempFile();
415
+ });
416
+ });
417
+ }
418
+ _destroy(error, callback) {
419
+ if (this.commitPromise) {
420
+ callback(error);
421
+ return;
422
+ }
423
+ if (!this.localStream.destroyed) {
424
+ this.localStream.destroy();
425
+ }
426
+ this.cleanupTempFile();
427
+ callback(error);
428
+ }
429
+ async commitUploadedFile() {
430
+ const parentPath = import_node_path2.default.posix.dirname(this.relativePath);
431
+ let fileName = import_node_path2.default.posix.basename(this.relativePath);
432
+ let uploadFilePath = this.tempFilePath;
433
+ if (this.runtime.crypto.isContentEncryptionEnabled()) {
434
+ const contentProfile = this.runtime.crypto.resolveProfile();
435
+ if (!contentProfile.password) {
436
+ throw new Error(`Missing encryption password for ${this.relativePath}`);
437
+ }
438
+ const encryptedTempFilePath = `${this.tempFilePath}.enc`;
439
+ const encryptor = await this.runtime.crypto.createFileEncryptor();
440
+ await encryptLocalFileWithEncryptor(
441
+ this.tempFilePath,
442
+ encryptedTempFilePath,
443
+ encryptor
444
+ );
445
+ uploadFilePath = encryptedTempFilePath;
446
+ fileName = (0, import_runtime.addContentEncryptionSuffix)(fileName);
447
+ }
448
+ const fileMetadata = createUploadFileMetadata(
449
+ uploadFilePath,
450
+ fileName,
451
+ this.runtime.workspaceRoot
452
+ );
453
+ const uploadController = typeof this.volume?.createUpload === "function" ? await this.volume.createUpload(parentPath, fileMetadata) : await this.volume.upload(parentPath, fileMetadata);
454
+ const uploadResult = isUploadHandle(uploadController) ? await uploadController.upload() : uploadController;
455
+ await syncUploadedFileCache(
456
+ this.volume,
457
+ parentPath,
458
+ fileName,
459
+ uploadResult
460
+ );
461
+ if (uploadFilePath !== this.tempFilePath && import_node_fs.default.existsSync(uploadFilePath)) {
462
+ import_node_fs.default.unlinkSync(uploadFilePath);
463
+ }
464
+ }
465
+ cleanupTempFile() {
466
+ if (this.tempFileRemoved) {
467
+ return;
468
+ }
469
+ this.tempFileRemoved = true;
470
+ try {
471
+ if (import_node_fs.default.existsSync(this.tempFilePath)) {
472
+ import_node_fs.default.unlinkSync(this.tempFilePath);
473
+ }
474
+ } catch {
475
+ }
476
+ }
477
+ };
478
+ async function encryptLocalFileWithEncryptor(inputPath, outputPath, encryptor) {
479
+ const chunkSize = 1024 * 1024;
480
+ const fileSize = import_node_fs.default.statSync(inputPath).size;
481
+ const inputFd = import_node_fs.default.openSync(inputPath, "r");
482
+ const outputFd = import_node_fs.default.openSync(outputPath, "w");
483
+ try {
484
+ let offset = 0;
485
+ const buffer = Buffer.allocUnsafe(chunkSize);
486
+ while (offset < fileSize) {
487
+ const bytesToRead = Math.min(chunkSize, fileSize - offset);
488
+ const bytesRead = import_node_fs.default.readSync(inputFd, buffer, 0, bytesToRead, offset);
489
+ if (bytesRead === 0) {
490
+ break;
491
+ }
492
+ const chunk = new Uint8Array(buffer.buffer, buffer.byteOffset, bytesRead);
493
+ const encryptedChunk = await encryptor.encryptChunk(chunk, offset);
494
+ import_node_fs.default.writeSync(outputFd, Buffer.from(encryptedChunk));
495
+ offset += bytesRead;
496
+ }
497
+ } finally {
498
+ import_node_fs.default.closeSync(inputFd);
499
+ import_node_fs.default.closeSync(outputFd);
500
+ }
501
+ }
502
+ var FileBoxFtpFileSystem = class extends import_ftp_srv2.FileSystem {
503
+ constructor(connection, runtime) {
504
+ super(connection, { root: "/", cwd: "/" });
505
+ this.runtime = runtime;
506
+ }
507
+ cryptPromise;
508
+ resolveClientPath(targetPath = ".") {
509
+ return resolveFtpClientReference(this.currentDirectory(), targetPath);
510
+ }
511
+ getApp() {
512
+ return this.runtime.app;
513
+ }
514
+ getCrypt() {
515
+ if (this.cryptPromise) {
516
+ return this.cryptPromise;
517
+ }
518
+ this.cryptPromise = (async () => {
519
+ if (!this.runtime.config.encryption?.password) {
520
+ return null;
521
+ }
522
+ return this.runtime.crypto.createFsCrypt();
523
+ })();
524
+ return this.cryptPromise;
525
+ }
526
+ async getDisplayName(rawName) {
527
+ const crypt = await this.getCrypt();
528
+ if (!crypt || !rawName) {
529
+ return rawName;
530
+ }
531
+ if (!await crypt.isEncrypted(rawName)) {
532
+ return rawName;
533
+ }
534
+ const decryptedName = await crypt.decryptFilename(rawName);
535
+ return decryptedName || rawName;
536
+ }
537
+ async decorateRemoteItem(item) {
538
+ const displayName = await this.getDisplayName(item?.name || "");
539
+ if (!displayName || displayName === item?.name) {
540
+ return item;
541
+ }
542
+ return {
543
+ ...item,
544
+ name: displayName,
545
+ original_name: displayName
546
+ };
547
+ }
548
+ async transformStoredName(name) {
549
+ if (!this.runtime.crypto.isFilenameEncryptionEnabled()) {
550
+ return name;
551
+ }
552
+ const crypt = await this.getCrypt();
553
+ if (!crypt) {
554
+ return name;
555
+ }
556
+ return crypt.encryptFilename(name);
557
+ }
558
+ splitMountedPath(clientPath) {
559
+ const normalizedPath = normalizeFtpClientPath(clientPath);
560
+ const segments = normalizedPath.split("/").filter(Boolean);
561
+ const mountName = segments[0] || "";
562
+ const relativePath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
563
+ return {
564
+ normalizedPath,
565
+ mountName,
566
+ relativePath: normalizeFtpClientPath(relativePath)
567
+ };
568
+ }
569
+ getMountedVolume(clientPath) {
570
+ const { normalizedPath, mountName, relativePath } = this.splitMountedPath(clientPath);
571
+ if (normalizedPath === "/") {
572
+ return {
573
+ mountName: "",
574
+ relativePath: "/",
575
+ volume: null
576
+ };
577
+ }
578
+ const app = this.getApp();
579
+ const volume = app.getVolume?.(mountName) || app.getVolume?.(`/${mountName}`) || app.volumeMap?.get?.(mountName) || app.volumeMap?.get?.(`/${mountName}`);
580
+ if (!volume) {
581
+ throw new Error(`Drive "${mountName}" is not mounted`);
582
+ }
583
+ return {
584
+ mountName,
585
+ relativePath,
586
+ volume
587
+ };
588
+ }
589
+ async findRemoteEntryByClientName(items, clientName) {
590
+ for (const item of items) {
591
+ if (item?.name === clientName) {
592
+ return item;
593
+ }
594
+ if (await this.getDisplayName(item?.name || "") === clientName) {
595
+ return item;
596
+ }
597
+ }
598
+ return null;
599
+ }
600
+ async resolveExistingMountedPath(clientPath) {
601
+ const mounted = this.getMountedVolume(clientPath);
602
+ if (!mounted.volume || mounted.relativePath === "/") {
603
+ return mounted;
604
+ }
605
+ const segments = mounted.relativePath.split("/").filter(Boolean);
606
+ let resolvedPath = "/";
607
+ for (const segment of segments) {
608
+ const listing = await mounted.volume.list(resolvedPath);
609
+ const items = Array.isArray(listing?.data) ? listing.data : [];
610
+ const matchedEntry = await this.findRemoteEntryByClientName(
611
+ items,
612
+ segment
613
+ );
614
+ if (!matchedEntry?.name) {
615
+ resolvedPath = normalizeFtpClientPath(
616
+ import_node_path2.default.posix.join(resolvedPath, segment)
617
+ );
618
+ continue;
619
+ }
620
+ resolvedPath = normalizeFtpClientPath(
621
+ import_node_path2.default.posix.join(resolvedPath, matchedEntry.name)
622
+ );
623
+ }
624
+ return {
625
+ ...mounted,
626
+ relativePath: resolvedPath
627
+ };
628
+ }
629
+ async getRootEntry(mountName) {
630
+ const result = await this.getApp().list("/");
631
+ const items = Array.isArray(result) ? result : result?.data || [];
632
+ const entry = items.find((item) => item?.name === mountName);
633
+ if (!entry) {
634
+ throw new Error(`Drive "${mountName}" is not mounted`);
635
+ }
636
+ return entry;
637
+ }
638
+ createTempFilePath() {
639
+ const tempRoot = this.runtime.config.tempPath;
640
+ return import_node_path2.default.join(
641
+ tempRoot,
642
+ `ftp-upload-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
643
+ );
644
+ }
645
+ async get(fileName) {
646
+ try {
647
+ const clientPath = this.resolveClientPath(fileName);
648
+ if (clientPath === "/") {
649
+ return toFileEntry(await this.getApp().stat("/"));
650
+ }
651
+ const { mountName, relativePath, volume } = await this.resolveExistingMountedPath(clientPath);
652
+ const stat = relativePath === "/" ? await this.getRootEntry(mountName) : await volume.stat(relativePath);
653
+ return toFileEntry(await this.decorateRemoteItem(stat));
654
+ } catch (error) {
655
+ throw toFileSystemError(error);
656
+ }
657
+ }
658
+ async list(targetPath = ".") {
659
+ try {
660
+ const clientPath = this.resolveClientPath(targetPath);
661
+ const mounted = clientPath === "/" ? null : await this.resolveExistingMountedPath(clientPath);
662
+ const result = mounted ? await mounted.volume.list(mounted.relativePath) : await this.getApp().list("/");
663
+ const items = Array.isArray(result) ? result : result?.data || [];
664
+ const decoratedItems = await Promise.all(
665
+ items.map((item) => this.decorateRemoteItem(item))
666
+ );
667
+ return decoratedItems.map((item) => toFileEntry(item));
668
+ } catch (error) {
669
+ throw toFileSystemError(error);
670
+ }
671
+ }
672
+ async chdir(targetPath = ".") {
673
+ try {
674
+ const clientPath = this.resolveClientPath(targetPath);
675
+ const mounted = clientPath === "/" ? null : await this.resolveExistingMountedPath(clientPath);
676
+ const stat = clientPath === "/" ? await this.getApp().stat("/") : mounted.relativePath === "/" ? await this.getRootEntry(mounted.mountName) : await mounted.volume.stat(mounted.relativePath);
677
+ if (stat?.type !== "folder" && stat?.directory !== true) {
678
+ throw new Error("Not a valid directory");
679
+ }
680
+ this.cwd = clientPath;
681
+ return this.currentDirectory();
682
+ } catch (error) {
683
+ throw toFileSystemError(error);
684
+ }
685
+ }
686
+ async read(fileName, options = {}) {
687
+ try {
688
+ const clientPath = this.resolveClientPath(fileName);
689
+ const { relativePath, volume } = await this.resolveExistingMountedPath(clientPath);
690
+ const stat = await volume.stat(relativePath);
691
+ if (stat?.type === "folder" || stat?.directory === true) {
692
+ throw new Error("Cannot read a directory");
693
+ }
694
+ const [downloadInfo] = await volume.download(relativePath);
695
+ if (!downloadInfo?.url) {
696
+ throw new Error("Download URL is not available");
697
+ }
698
+ const decryptProfile = resolveFtpDownloadDecryption({
699
+ clientPath,
700
+ downloadInfo,
701
+ config: this.runtime.config
702
+ });
703
+ const startOffset = typeof options.start === "number" ? options.start : 0;
704
+ const localDownloadPath = resolveReadableDownloadPath(downloadInfo.url);
705
+ if (localDownloadPath) {
706
+ const sourceStream2 = import_node_fs.default.createReadStream(localDownloadPath, {
707
+ start: typeof options.start === "number" ? options.start : void 0
708
+ });
709
+ if (!decryptProfile) {
710
+ return {
711
+ stream: sourceStream2,
712
+ clientPath
713
+ };
714
+ }
715
+ const totalSize2 = import_node_fs.default.statSync(localDownloadPath).size;
716
+ const encryptedBytes2 = (0, import_runtime.calculateEncryptedBytes)(
717
+ totalSize2,
718
+ decryptProfile.partialEncryption
719
+ );
720
+ const encryptor2 = await (0, import_runtime.createFileEncryptor)(decryptProfile);
721
+ return {
722
+ stream: sourceStream2.pipe(
723
+ new DownloadDecryptTransform(
724
+ encryptor2,
725
+ startOffset,
726
+ encryptedBytes2
727
+ )
728
+ ),
729
+ clientPath
730
+ };
731
+ }
732
+ const headers = { ...downloadInfo.headers || {} };
733
+ if (typeof options.start === "number" && options.start > 0 && !("Range" in headers) && !("range" in headers)) {
734
+ headers.Range = `bytes=${options.start}-`;
735
+ }
736
+ const response = await fetch(downloadInfo.url, { headers });
737
+ if (!response.ok && response.status !== 206) {
738
+ throw new Error(
739
+ `Failed to fetch remote file: ${response.status} ${response.statusText}`
740
+ );
741
+ }
742
+ if (!response.body) {
743
+ throw new Error("Remote response does not contain a readable body");
744
+ }
745
+ const totalSize = parseRemoteResponseTotalSize(response, stat?.byte);
746
+ const sourceStream = import_node_stream.Readable.fromWeb(response.body);
747
+ if (!decryptProfile) {
748
+ return {
749
+ stream: sourceStream,
750
+ clientPath
751
+ };
752
+ }
753
+ const encryptedBytes = (0, import_runtime.calculateEncryptedBytes)(
754
+ totalSize,
755
+ decryptProfile.partialEncryption
756
+ );
757
+ const encryptor = await (0, import_runtime.createFileEncryptor)(decryptProfile);
758
+ return {
759
+ stream: sourceStream.pipe(
760
+ new DownloadDecryptTransform(encryptor, startOffset, encryptedBytes)
761
+ ),
762
+ clientPath
763
+ };
764
+ } catch (error) {
765
+ throw toFileSystemError(error);
766
+ }
767
+ }
768
+ async write(fileName, options = {}) {
769
+ const { append = false, start } = options;
770
+ if (append || typeof start === "number") {
771
+ throw new Error("Append and ranged uploads are not supported");
772
+ }
773
+ const clientPath = this.resolveClientPath(fileName);
774
+ const mounted = this.getMountedVolume(clientPath);
775
+ const parentClientPath = normalizeFtpClientPath(
776
+ import_node_path2.default.posix.dirname(clientPath)
777
+ );
778
+ const parentMounted = parentClientPath === "/" ? mounted : await this.resolveExistingMountedPath(parentClientPath);
779
+ const relativePath = normalizeFtpClientPath(
780
+ import_node_path2.default.posix.join(
781
+ parentMounted.relativePath,
782
+ await this.transformStoredName(
783
+ import_node_path2.default.posix.basename(mounted.relativePath)
784
+ )
785
+ )
786
+ );
787
+ const { volume } = mounted;
788
+ const stream = new DeferredUploadWriteStream(
789
+ volume,
790
+ relativePath,
791
+ this.createTempFilePath(),
792
+ this.runtime
793
+ );
794
+ return {
795
+ stream,
796
+ clientPath
797
+ };
798
+ }
799
+ async delete(targetPath) {
800
+ try {
801
+ const clientPath = this.resolveClientPath(targetPath);
802
+ if (clientPath === "/") {
803
+ throw new Error("Cannot delete root directory");
804
+ }
805
+ const { relativePath, volume } = await this.resolveExistingMountedPath(clientPath);
806
+ return await volume.remove(relativePath);
807
+ } catch (error) {
808
+ throw toFileSystemError(error);
809
+ }
810
+ }
811
+ async mkdir(targetPath) {
812
+ try {
813
+ const clientPath = this.resolveClientPath(targetPath);
814
+ const mounted = this.getMountedVolume(clientPath);
815
+ const parentClientPath = normalizeFtpClientPath(
816
+ import_node_path2.default.posix.dirname(clientPath)
817
+ );
818
+ const parentMounted = parentClientPath === "/" ? mounted : await this.resolveExistingMountedPath(parentClientPath);
819
+ const relativePath = normalizeFtpClientPath(
820
+ import_node_path2.default.posix.join(
821
+ parentMounted.relativePath,
822
+ await this.transformStoredName(
823
+ import_node_path2.default.posix.basename(mounted.relativePath)
824
+ )
825
+ )
826
+ );
827
+ const { volume } = mounted;
828
+ return await volume.mkdir(relativePath, {});
829
+ } catch (error) {
830
+ throw toFileSystemError(error);
831
+ }
832
+ }
833
+ async rename(from, to) {
834
+ try {
835
+ const fromPath = this.resolveClientPath(from);
836
+ const toPath = this.resolveClientPath(to);
837
+ const fromMounted = await this.resolveExistingMountedPath(fromPath);
838
+ const toMounted = this.getMountedVolume(toPath);
839
+ if (fromMounted.mountName !== toMounted.mountName) {
840
+ throw new Error("Cross-drive rename is not supported");
841
+ }
842
+ const fromDir = import_node_path2.default.posix.dirname(fromMounted.relativePath);
843
+ const toDirClientPath = normalizeFtpClientPath(
844
+ import_node_path2.default.posix.dirname(toPath)
845
+ );
846
+ const resolvedToParent = toDirClientPath === "/" ? toMounted : await this.resolveExistingMountedPath(toDirClientPath);
847
+ const toDir = resolvedToParent.relativePath;
848
+ const targetRelativePath = normalizeFtpClientPath(
849
+ import_node_path2.default.posix.join(
850
+ resolvedToParent.relativePath,
851
+ await this.transformStoredName(
852
+ import_node_path2.default.posix.basename(toMounted.relativePath)
853
+ )
854
+ )
855
+ );
856
+ if (fromDir === toDir) {
857
+ return await fromMounted.volume.rename(
858
+ fromMounted.relativePath,
859
+ import_node_path2.default.posix.basename(targetRelativePath)
860
+ );
861
+ }
862
+ if (typeof fromMounted.volume.move !== "function") {
863
+ throw new Error("Move is not supported by this drive");
864
+ }
865
+ return await fromMounted.volume.move(
866
+ fromMounted.relativePath,
867
+ targetRelativePath
868
+ );
869
+ } catch (error) {
870
+ throw toFileSystemError(error);
871
+ }
872
+ }
873
+ };
874
+
875
+ // src/adapters/filebox/runtime.ts
876
+ function resolveMountedDriveNames(app, mountedDriveNames) {
877
+ if (mountedDriveNames && mountedDriveNames.length > 0) {
878
+ return mountedDriveNames;
879
+ }
880
+ const volumeMap = app?.volumeMap;
881
+ if (!(volumeMap instanceof Map)) {
882
+ return [];
883
+ }
884
+ return Array.from(volumeMap.values()).map((volume) => volume?._options?.name || volume?.name || "").map((name) => String(name || "").replace(/^\/+/, "")).filter(Boolean);
885
+ }
886
+ function createRuntime(options) {
887
+ if (!options.runtime) {
888
+ throw new Error("createRuntime requires a runtime instance.");
889
+ }
890
+ return Object.assign(options.runtime, {
891
+ mountedDriveNames: resolveMountedDriveNames(
892
+ options.runtime.app,
893
+ options.mountedDriveNames
894
+ )
895
+ });
896
+ }
897
+
898
+ // src/adapters/filebox/session.ts
899
+ function createFileBoxFtpSessionFactory(runtime) {
900
+ return async () => ({
901
+ root: "/",
902
+ fs: new FileBoxFtpFileSystem(void 0, runtime)
903
+ });
904
+ }
905
+
906
+ // src/adapters/workspace/index.ts
907
+ var import_runtime3 = require("@filebox/runtime");
908
+ async function mountEnabledDrives(runtime, options = {}) {
909
+ const onlyDrive = options.onlyDrive?.trim();
910
+ const drives = await runtime.drives.list();
911
+ const mountedNames = [];
912
+ for (const drive of drives) {
913
+ if (!drive.enabled) {
914
+ continue;
915
+ }
916
+ if (onlyDrive && drive.name !== onlyDrive) {
917
+ continue;
918
+ }
919
+ if (!drive.supported) {
920
+ console.warn(
921
+ `[ftp-server] Skip drive "${drive.name}": provider "${drive.provider}" is not loaded.`
922
+ );
923
+ continue;
924
+ }
925
+ if (!drive.ready) {
926
+ console.warn(
927
+ `[ftp-server] Skip drive "${drive.name}": ${drive.issue || "configuration is incomplete."}`
928
+ );
929
+ continue;
930
+ }
931
+ try {
932
+ await runtime.app.mount(drive);
933
+ mountedNames.push(drive.name);
934
+ } catch (error) {
935
+ const message = error instanceof Error ? error.message : String(error);
936
+ console.warn(
937
+ `[ftp-server] Failed to mount drive "${drive.name}": ${message}`
938
+ );
939
+ }
940
+ }
941
+ return mountedNames;
942
+ }
943
+ async function createWorkspaceRuntime(options = {}) {
944
+ const { configPath, config } = (0, import_runtime3.loadWorkspaceConfigInput)({
945
+ workspaceRoot: options.workspaceRoot || null
946
+ });
947
+ const runtime = await import_runtime3.FileBoxRuntime.create({
948
+ configPath,
949
+ config
950
+ });
951
+ const mountedDriveNames = await mountEnabledDrives(runtime, options);
952
+ return Object.assign(runtime, {
953
+ mountedDriveNames
954
+ });
955
+ }
956
+ // Annotate the CommonJS export names for ESM import in node:
957
+ 0 && (module.exports = {
958
+ FileBoxFtpFileSystem,
959
+ FileBoxFtpServer,
960
+ createFileBoxFtpSessionFactory,
961
+ createRuntime,
962
+ createStaticFtpAuthenticator,
963
+ createWorkspaceRuntime,
964
+ normalizeServerCredentials
965
+ });
package/package.json CHANGED
@@ -1,23 +1,21 @@
1
1
  {
2
2
  "name": "@filebox/ftp-server",
3
- "version": "1.0.0",
4
- "description": "FTP server library for FileBox.",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js",
12
- "default": "./dist/index.js"
13
- }
14
- },
15
- "files": [
16
- "dist/index.js",
17
- "dist/**/*.d.ts",
18
- "LICENSE",
19
- "CHANGELOG.md",
20
- "README.md"
3
+ "version": "1.0.1",
4
+ "description": "FTP server library for FileBox.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.cjs",
14
+ "default": "./dist/index.mjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
21
19
  ],
22
20
  "publishConfig": {
23
21
  "access": "public"
@@ -27,20 +25,20 @@
27
25
  "ftp",
28
26
  "ftp-server"
29
27
  ],
30
- "license": "MIT",
31
- "scripts": {
32
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --packages=external --outfile=dist/index.js && tsc -p tsconfig.json --emitDeclarationOnly",
33
- "prepack": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --packages=external --outfile=dist/index.js && tsc -p tsconfig.json --emitDeclarationOnly",
34
- "typecheck": "tsc --noEmit -p tsconfig.json"
35
- },
36
- "dependencies": {
37
- "@filebox/runtime": "^1.0.0",
38
- "ftp-srv": "^4.6.3"
39
- },
40
- "devDependencies": {
41
- "@types/node": "^24.0.13",
42
- "esbuild": "^0.24.0",
43
- "tsx": "^4.20.5",
44
- "typescript": "^5.5.4"
45
- }
46
- }
28
+ "license": "MIT",
29
+ "scripts": {
30
+ "build": "node build.mjs",
31
+ "prepack": "node build.mjs",
32
+ "typecheck": "tsc --noEmit -p tsconfig.json"
33
+ },
34
+ "dependencies": {
35
+ "@filebox/runtime": "^1.0.0",
36
+ "ftp-srv": "^4.6.3"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^24.0.13",
40
+ "esbuild": "^0.24.0",
41
+ "tsx": "^4.20.5",
42
+ "typescript": "^5.5.4"
43
+ }
44
+ }
package/CHANGELOG.md DELETED
@@ -1,2 +0,0 @@
1
- # Changelog
2
-
File without changes