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