@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 +2 -0
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/dist/adapters/filebox/index.d.ts +2 -0
- package/dist/adapters/filebox/runtime.d.ts +9 -0
- package/dist/adapters/filebox/session.d.ts +3 -0
- package/dist/adapters/workspace/index.d.ts +6 -0
- package/dist/filesystem/index.d.ts +65 -0
- package/dist/filesystem/path.d.ts +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +930 -0
- package/dist/server/auth.d.ts +8 -0
- package/dist/server/index.d.ts +14 -0
- package/dist/server/types.d.ts +38 -0
- package/package.json +46 -0
package/CHANGELOG.md
ADDED
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,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,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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|