@lemoncat7/dsh-ssh 1.2.0 → 1.3.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/ARCHITECTURE.md +18 -3
- package/README.md +16 -3
- package/lib/api.d.ts +8 -0
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +218 -13
- package/lib/api.js.map +1 -1
- package/lib/client-api.d.ts +66 -0
- package/lib/client-api.d.ts.map +1 -1
- package/lib/client-api.js +14 -0
- package/lib/client-api.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +467 -97
- package/lib/client.js.map +4 -4
- package/lib/connector.js +2 -2
- package/lib/connector.js.map +1 -1
- package/lib/credentials.d.ts +9 -0
- package/lib/credentials.d.ts.map +1 -1
- package/lib/credentials.js +16 -0
- package/lib/credentials.js.map +1 -1
- package/lib/domain.d.ts +42 -1
- package/lib/domain.d.ts.map +1 -1
- package/lib/domain.js +32 -0
- package/lib/domain.js.map +1 -1
- package/lib/endpoint-session-manager.d.ts +16 -0
- package/lib/endpoint-session-manager.d.ts.map +1 -0
- package/lib/endpoint-session-manager.js +70 -0
- package/lib/endpoint-session-manager.js.map +1 -0
- package/lib/file-transfer-manager.d.ts +50 -0
- package/lib/file-transfer-manager.d.ts.map +1 -0
- package/lib/file-transfer-manager.js +257 -0
- package/lib/file-transfer-manager.js.map +1 -0
- package/lib/file-transfer-tools.d.ts +6 -0
- package/lib/file-transfer-tools.d.ts.map +1 -0
- package/lib/file-transfer-tools.js +143 -0
- package/lib/file-transfer-tools.js.map +1 -0
- package/lib/file-transfer-workspace.d.ts +10 -0
- package/lib/file-transfer-workspace.d.ts.map +1 -0
- package/lib/file-transfer-workspace.js +299 -0
- package/lib/file-transfer-workspace.js.map +1 -0
- package/lib/ftp-adapter.d.ts +18 -0
- package/lib/ftp-adapter.d.ts.map +1 -0
- package/lib/ftp-adapter.js +240 -0
- package/lib/ftp-adapter.js.map +1 -0
- package/lib/ftp-profile-editor.d.ts +9 -0
- package/lib/ftp-profile-editor.d.ts.map +1 -0
- package/lib/ftp-profile-editor.js +89 -0
- package/lib/ftp-profile-editor.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +17 -2
- package/lib/index.js.map +1 -1
- package/lib/network-dialer.d.ts +12 -0
- package/lib/network-dialer.d.ts.map +1 -0
- package/lib/network-dialer.js +27 -0
- package/lib/network-dialer.js.map +1 -0
- package/lib/proxy.d.ts +3 -3
- package/lib/proxy.d.ts.map +1 -1
- package/lib/proxy.js +11 -7
- package/lib/proxy.js.map +1 -1
- package/lib/remote-file-systems.d.ts +9 -0
- package/lib/remote-file-systems.d.ts.map +1 -0
- package/lib/remote-file-systems.js +23 -0
- package/lib/remote-file-systems.js.map +1 -0
- package/lib/remote-files.d.ts +51 -0
- package/lib/remote-files.d.ts.map +1 -0
- package/lib/remote-files.js +36 -0
- package/lib/remote-files.js.map +1 -0
- package/lib/remote-workspace-tree.d.ts.map +1 -1
- package/lib/remote-workspace-tree.js +5 -15
- package/lib/remote-workspace-tree.js.map +1 -1
- package/lib/session-access.d.ts +3 -0
- package/lib/session-access.d.ts.map +1 -1
- package/lib/session-access.js +4 -1
- package/lib/session-access.js.map +1 -1
- package/lib/sftp-adapter.d.ts +13 -0
- package/lib/sftp-adapter.d.ts.map +1 -0
- package/lib/sftp-adapter.js +28 -0
- package/lib/sftp-adapter.js.map +1 -0
- package/lib/sftp.d.ts +7 -0
- package/lib/sftp.d.ts.map +1 -1
- package/lib/sftp.js +215 -6
- package/lib/sftp.js.map +1 -1
- package/lib/store.d.ts +3 -1
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +51 -3
- package/lib/store.js.map +1 -1
- package/lib/tools.d.ts +3 -1
- package/lib/tools.d.ts.map +1 -1
- package/lib/tools.js +4 -1
- package/lib/tools.js.map +1 -1
- package/package.json +7 -2
package/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DSH SSH architecture
|
|
2
2
|
|
|
3
|
-
This plugin is split into four boundaries. UI code never reaches
|
|
3
|
+
This plugin is split into four boundaries. UI code never reaches SSH or file transports directly, and server code never depends on the DSH client runtime.
|
|
4
4
|
|
|
5
5
|
## Runtime boundaries
|
|
6
6
|
|
|
@@ -12,10 +12,15 @@ This plugin is split into four boundaries. UI code never reaches the SSH transpo
|
|
|
12
12
|
|
|
13
13
|
### Server application
|
|
14
14
|
|
|
15
|
-
- `src/api.ts` translates HTTP routes into explicit store, terminal,
|
|
15
|
+
- `src/api.ts` translates HTTP routes into explicit store, terminal, file-transfer, forwarding, and credential operations.
|
|
16
16
|
- `src/store.ts` owns persistent SSH configuration.
|
|
17
17
|
- `src/session-access.ts` and `src/tools.ts` define the session authorization boundary used by AI tools.
|
|
18
|
-
- `src/
|
|
18
|
+
- `src/remote-files.ts` defines the protocol-neutral endpoint and remote filesystem contract.
|
|
19
|
+
- `src/sftp-adapter.ts` and `src/ftp-adapter.ts` implement that contract without leaking protocol details upward.
|
|
20
|
+
- `src/network-dialer.ts` owns routed TCP creation for FTP control and passive data connections.
|
|
21
|
+
- `src/endpoint-session-manager.ts` owns sequential, idle-reaped browser pane sessions.
|
|
22
|
+
- `src/file-transfer-manager.ts` owns bounded asynchronous jobs, recursive scans, stream backpressure, progress, cancellation, and cleanup.
|
|
23
|
+
- `src/connector.ts`, `src/terminal.ts`, `src/sftp.ts`, and `src/forwards.ts` retain SSH-specific resources and cleanup.
|
|
19
24
|
|
|
20
25
|
### Browser features
|
|
21
26
|
|
|
@@ -23,6 +28,8 @@ This plugin is split into four boundaries. UI code never reaches the SSH transpo
|
|
|
23
28
|
- `src/profile-editor.tsx` owns connection editing, validation, jump chains, and deletion safeguards.
|
|
24
29
|
- `src/remote-workspace-tree.tsx` owns host mounting, fixed directories, and remote-session creation.
|
|
25
30
|
- `src/sftp-client.tsx` owns directory browsing, upload, preview, and download.
|
|
31
|
+
- `src/file-transfer-workspace.tsx` owns transfer task tabs, 2–4 file panes, cross-pane actions, job feedback, and file authorization UI.
|
|
32
|
+
- `src/ftp-profile-editor.tsx` owns FTP/FTPS connection editing and validation.
|
|
26
33
|
- `src/resizable-split.tsx` owns the terminal/SFTP split and persisted sizing.
|
|
27
34
|
- `src/ui-components.tsx` provides shared dialog, field, segment, and empty-state behavior.
|
|
28
35
|
|
|
@@ -37,7 +44,11 @@ This plugin is split into four boundaries. UI code never reaches the SSH transpo
|
|
|
37
44
|
| State | Owner | Persistence |
|
|
38
45
|
| --- | --- | --- |
|
|
39
46
|
| SSH profiles, proxies, credentials, forwards | server store | profile data + DSH credential service |
|
|
47
|
+
| FTP/FTPS profiles | server store | profile data + DSH credential service |
|
|
40
48
|
| Mounted hosts, permission, fixed directories | session access store | per DSH session |
|
|
49
|
+
| Authorized file endpoints and file permission | session access store | per DSH session |
|
|
50
|
+
| Browser file control sessions | endpoint session manager | process lifetime, 60-second idle reap |
|
|
51
|
+
| File transfer jobs | file transfer manager | process lifetime |
|
|
41
52
|
| Browser terminal process | terminal manager | process lifetime |
|
|
42
53
|
| Active panel, selected host, open dialog | React feature component | browser lifetime |
|
|
43
54
|
| Terminal/SFTP split width | `ResizableSplit` | browser local storage |
|
|
@@ -46,6 +57,10 @@ This plugin is split into four boundaries. UI code never reaches the SSH transpo
|
|
|
46
57
|
|
|
47
58
|
- The plugin uses only public DSH slots and injected client services; it does not patch DSH source code.
|
|
48
59
|
- A tool call must pass the session access boundary before reaching SSH resources.
|
|
60
|
+
- SSH command permission and remote-file permission are independent; authorizing one never implies the other.
|
|
61
|
+
- FTP control and passive data sockets use the same route policy. FTPS wraps both socket classes with verified TLS.
|
|
62
|
+
- Remote-to-remote transfers use backpressured streams and never stage a complete file on local disk.
|
|
63
|
+
- Browser browsing sessions and transfer job sessions are isolated so a long transfer cannot block pane navigation.
|
|
49
64
|
- Secrets are write-only from the browser and are never returned by profile APIs.
|
|
50
65
|
- Terminal input is sequenced and terminal resources are explicitly disposed on close or unmount.
|
|
51
66
|
- Visual motion uses `transform` and `opacity`, remains interruptible, and is disabled by `prefers-reduced-motion`.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-ssh
|
|
2
2
|
|
|
3
|
-
面向 DeepSeek Harness
|
|
3
|
+
面向 DeepSeek Harness 的远端工作插件。它把 SSH 会话管理、浏览器终端、FTP/FTPS/SFTP 文件传输、代理、端口转发和 AI 会话授权放在同一个 DSH 工作区里。
|
|
4
4
|
|
|
5
5
|
浏览器 UI、服务端运行时、会话授权边界和终端生命周期的职责说明见 [ARCHITECTURE.md](./ARCHITECTURE.md)。
|
|
6
6
|
|
|
@@ -33,6 +33,9 @@ dsh plugin --profile web add @lemoncat7/dsh-ssh@latest
|
|
|
33
33
|
- 当前会话右侧栏支持多个 AI 终端切换与键盘输入
|
|
34
34
|
- AI 调用 `ssh_terminal_open` 创建终端时,当前会话会自动展开 SSH 右栏并切换到新终端
|
|
35
35
|
- SFTP 文件浏览、文本/图片/PDF 预览和流式下载;支持拖放或选择多个文件顺序上传,并可从内嵌预览打开独立放大窗口
|
|
36
|
+
- 独立「文件传输」工作区,支持 2~4 个 FTP、FTPS 或 SFTP 窗格与多个任务页
|
|
37
|
+
- FTP、显式 FTPS、隐式 FTPS;FTP 控制连接与每条被动数据连接统一支持 HTTP CONNECT 和 SOCKS5 代理
|
|
38
|
+
- FTP/FTPS/SFTP 跨协议流式互传,不把完整文件暂存到 DSH 磁盘;支持多文件、递归目录、冲突策略、进度、取消和失败状态
|
|
36
39
|
- 有序多主机跳板链,兼容已有单跳板配置
|
|
37
40
|
- 独立密钥库集中保存常用用户名、密码和私钥,SSH 连接仅保存引用
|
|
38
41
|
- 独立代理库集中保存 HTTP CONNECT 与 SOCKS5 代理,多台主机可复用同一连接路径
|
|
@@ -49,9 +52,11 @@ dsh plugin --profile web add @lemoncat7/dsh-ssh@latest
|
|
|
49
52
|
|
|
50
53
|
管理工作区分成两部分:
|
|
51
54
|
|
|
52
|
-
1.
|
|
55
|
+
1. 左侧远端树:点击主机只负责选中并展开固定目录,同时自动收起其他主机;右侧独立的「挂载 / 卸载」按钮决定当前 DSH 会话能否访问该主机,因此浏览终端不会意外改变 AI 权限。点击目录整行即可固定,再次点击已固定目录即可取消;每台主机最多固定一个目录。目录后的新增按钮直接创建并打开新会话。底部统一设置「仅命令 / 终端控制」与执行前确认。
|
|
53
56
|
2. 右侧工作区:默认把终端与当前主机或项目目录的 SFTP 左右并排展示,不需要在两个页面之间切换;SFTP 会等终端首次连接成功后再读取远端目录。拖动两栏之间的分隔线可以调整 SFTP 宽度,双击恢复默认,比例会保存在当前浏览器。另可进入端口转发、密钥库、代理库和设置。SFTP 支持浏览、预览、下载,以及拖放或文件选择器触发的多文件顺序上传;同名文件逐项确认跳过或覆盖,单文件上限为 512 MB。内嵌文件预览可通过下载旁的放大按钮打开独立预览窗口。
|
|
54
57
|
|
|
58
|
+
「文件传输」是独立工作区页签。每个任务页默认双窗格,可切换为 2~4 栏。每个窗格先显示统一的 SFTP、FTP、FTPS 连接列表,单击连接后才读取远端目录,并可随时返回连接列表。文件既可以拖到另一窗格,也可以选中后使用“传送到下一栏”,因此键盘和触控环境不依赖拖拽。浏览窗格复用自己的协议会话,传输任务使用独立连接,避免大文件占用目录浏览的控制通道;任务完成、失败或取消后只刷新对应目标目录,及时呈现完整或部分写入结果。选中的远端文件或目录可以在插件内确认后删除,目录采用有深度和数量上限的递归删除,且不会跟随符号链接。关闭页面不会中断服务端任务;DSH 进程停止时会统一取消并释放两端连接。
|
|
59
|
+
|
|
55
60
|
工作区使用插件内职责单一的自适应壳布局。宽容器使用双栏;窄于 820px 时远端树切换为左侧抽屉,窄于 520px 时进一步压缩 SFTP 次要信息和操作密度。响应式判断使用容器查询,因此手机、桌面分屏和 DSH 窄面板采用同一套行为。
|
|
56
61
|
|
|
57
62
|
SSH 插件不会向聊天标题栏添加按钮。右侧栏统一从左侧「远端」区域打开;当前会话尚未授权主机时会显示引导。仅执行命令权限显示 SFTP 目录;终端控制权限同时显示 SFTP 目录和 AI 终端。多个终端通过紧凑标签切换,运行中的终端支持键盘输入。进入 SFTP 子目录时,该目录会同步成为后续 `ssh_exec` 与新终端的工作目录。
|
|
@@ -73,8 +78,13 @@ SSH 插件不会向聊天标题栏添加按钮。右侧栏统一从左侧「远
|
|
|
73
78
|
| `ssh_forward_list` | 列出已授权连接的转发规则 |
|
|
74
79
|
| `ssh_forward_start` | 启动已有转发规则 |
|
|
75
80
|
| `ssh_forward_stop` | 停止已有转发规则 |
|
|
81
|
+
| `file_endpoint_list` | 只列出当前 DSH 会话明确授权的 FTP/FTPS/SFTP 端点 |
|
|
82
|
+
| `file_directory_list` | 浏览已授权端点中的远端目录 |
|
|
83
|
+
| `file_transfer_start` | 启动端点之间的异步流式文件或目录传输 |
|
|
84
|
+
| `file_transfer_status` | 查询当前会话拥有的传输任务进度 |
|
|
85
|
+
| `file_transfer_cancel` | 取消当前会话拥有的传输任务 |
|
|
76
86
|
|
|
77
|
-
工具始终从 `exec.agent.session.id`
|
|
87
|
+
工具始终从 `exec.agent.session.id` 解析会话授权关系。SSH 命令权限与文件权限独立:文件端点必须单独授权,并选择“仅浏览”或“允许跨端传输”。模型不能通过参数绕过授权,也不能枚举其他 DSH 会话的主机、文件端点、终端或传输任务。覆盖目标文件不会被默认推断;`file_transfer_start` 默认使用 `fail` 冲突策略。
|
|
78
88
|
|
|
79
89
|
「执行前确认」依赖 DSH 当前会话的审批策略:Workspace Write 的 Ask 策略会显示确认;Full Access 使用 Never 策略,不显示确认,并会直接拒绝被 SSH 插件标记为需要审批的操作。若希望在 Full Access 下直接执行 SSH,请关闭该开关。
|
|
80
90
|
|
|
@@ -86,11 +96,14 @@ SSH 插件不会向聊天标题栏添加按钮。右侧栏统一从左侧「远
|
|
|
86
96
|
- 私钥
|
|
87
97
|
- 私钥口令
|
|
88
98
|
- 代理密码
|
|
99
|
+
- FTP 密码
|
|
89
100
|
|
|
90
101
|
密钥库条目使用独立的 `dsh-ssh-vault/<credential-id>` Grant Record。SSH Profile 只保存 `credentialId`,不会复制或读回密钥库中的明文。仍被连接引用的密钥库条目不能删除。
|
|
91
102
|
|
|
92
103
|
代理库条目保存在状态文件中,代理密码使用独立的 `dsh-ssh-proxy/<proxy-id>` Grant Record。SSH Profile 选择常用代理后只保存 `proxyId`;仍被主机引用的代理不能删除。原有内联 HTTP/SOCKS5 配置继续兼容。
|
|
93
104
|
|
|
105
|
+
FTP Profile 与 SSH Profile 分开保存。FTP Profile 自有密码使用 `dsh-ftp/<profile-id>` Grant Record,也可以引用已有的密码型密钥库条目。普通 FTP 会在界面中明确标记为未加密;FTPS 默认校验证书。FTP 仅使用被动模式,且忽略 PASV 响应中用于改变目标主机的地址,避免 FTP Bounce/SSRF。
|
|
106
|
+
|
|
94
107
|
管理 API 和 Web UI只返回是否配置以及字段名,不返回任何凭据值。默认拒绝非回环地址的端口监听;若确实需要监听 `0.0.0.0`,必须在「远端 → 设置」中显式开启。
|
|
95
108
|
|
|
96
109
|
首次连接会拒绝未知主机密钥并展示 SHA-256 指纹。用户确认后才把指纹写入 Profile,后续连接严格比对。
|
package/lib/api.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { ForwardManager } from './forwards.js';
|
|
|
5
5
|
import { SshStore } from './store.js';
|
|
6
6
|
import { AiTerminalManager, BrowserTerminalManager } from './terminal.js';
|
|
7
7
|
import { ActivityEventBus } from './activity-events.js';
|
|
8
|
+
import { NetworkDialer } from './network-dialer.js';
|
|
9
|
+
import { RemoteFileSystems } from './remote-file-systems.js';
|
|
10
|
+
import { FileTransferManager } from './file-transfer-manager.js';
|
|
11
|
+
import { EndpointSessionManager } from './endpoint-session-manager.js';
|
|
8
12
|
export interface WebServerLike {
|
|
9
13
|
register(route: {
|
|
10
14
|
kind: 'prefix';
|
|
@@ -20,6 +24,10 @@ export interface SshApiRuntime {
|
|
|
20
24
|
terminals: BrowserTerminalManager;
|
|
21
25
|
aiTerminals: AiTerminalManager;
|
|
22
26
|
activityEvents: ActivityEventBus;
|
|
27
|
+
dialer: NetworkDialer;
|
|
28
|
+
files: RemoteFileSystems;
|
|
29
|
+
transfers: FileTransferManager;
|
|
30
|
+
fileSessions: EndpointSessionManager;
|
|
23
31
|
sessionCwd(sessionId: string): string | undefined;
|
|
24
32
|
}
|
|
25
33
|
export declare function registerSshApi(webServer: WebServerLike, prefix: string, runtime: SshApiRuntime): () => void;
|
package/lib/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,EAAwB,YAAY,EAAE,MAAM,gBAAgB,CAAA;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAKrD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAErC,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAGzE,OAAO,EAAE,gBAAgB,EAAwB,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAEhE,OAAO,EAAwB,YAAY,EAAE,MAAM,gBAAgB,CAAA;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAKrD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AAC9C,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAErC,OAAO,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAA;AAGzE,OAAO,EAAE,gBAAgB,EAAwB,MAAM,sBAAsB,CAAA;AAG7E,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAA;AAC5D,OAAO,EAAE,mBAAmB,EAA+B,MAAM,4BAA4B,CAAA;AAC7F,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAA;AAKtE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE;QACd,IAAI,EAAE,QAAQ,CAAA;QACd,IAAI,EAAE,MAAM,CAAA;QACZ,OAAO,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;KACzE,GAAG,MAAM,IAAI,CAAA;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,QAAQ,CAAA;IACf,WAAW,EAAE,kBAAkB,CAAA;IAC/B,SAAS,EAAE,YAAY,CAAA;IACvB,QAAQ,EAAE,cAAc,CAAA;IACxB,SAAS,EAAE,sBAAsB,CAAA;IACjC,WAAW,EAAE,iBAAiB,CAAA;IAC9B,cAAc,EAAE,gBAAgB,CAAA;IAChC,MAAM,EAAE,aAAa,CAAA;IACrB,KAAK,EAAE,iBAAiB,CAAA;IACxB,SAAS,EAAE,mBAAmB,CAAA;IAC9B,YAAY,EAAE,sBAAsB,CAAA;IACpC,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;CAClD;AAED,wBAAgB,cAAc,CAAC,SAAS,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,MAAM,IAAI,CAa3G"}
|
package/lib/api.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { pipeline } from 'node:stream/promises';
|
|
3
3
|
import { HostKeyRequiredError } from './connector.js';
|
|
4
|
-
import { normalizeCredentialEntryDraft, normalizeForwardDraft, normalizeProfileDraft, normalizeProxyEntryDraft, normalizeRemoteProjectDraft, normalizeSecrets, } from './domain.js';
|
|
4
|
+
import { normalizeCredentialEntryDraft, normalizeForwardDraft, normalizeFtpProfileDraft, normalizeProfileDraft, normalizeProxyEntryDraft, normalizeRemoteProjectDraft, normalizeSecrets, } from './domain.js';
|
|
5
5
|
import { setSessionDirectory } from './directory.js';
|
|
6
6
|
import { streamTerminalOutput } from './terminal-stream.js';
|
|
7
7
|
import { listSftpDirectory, openSftpFile, readSftpFilePreview, uploadSftpFile } from './sftp.js';
|
|
8
8
|
import { streamActivityEvents } from './activity-events.js';
|
|
9
9
|
import { listLocalWorkspace, openLocalWorkspaceFile, readLocalWorkspacePreview } from './local-workspace.js';
|
|
10
|
+
import { connectFtpProfile } from './ftp-adapter.js';
|
|
10
11
|
const MAX_BODY_BYTES = 1_048_576;
|
|
11
12
|
const MAX_SFTP_UPLOAD_BYTES = 512 * 1024 * 1024;
|
|
12
13
|
export function registerSshApi(webServer, prefix, runtime) {
|
|
@@ -30,7 +31,138 @@ async function dispatch(req, res, prefix, runtime) {
|
|
|
30
31
|
const segments = relative ? relative.split('/').map(decodeURIComponent) : [];
|
|
31
32
|
const method = req.method ?? 'GET';
|
|
32
33
|
if (method === 'GET' && segments[0] === 'health')
|
|
33
|
-
return sendJson(res, 200, { ok: true, service: 'dsh-ssh', schemaVersion:
|
|
34
|
+
return sendJson(res, 200, { ok: true, service: 'dsh-ssh', schemaVersion: 5 });
|
|
35
|
+
if (segments[0] === 'file-transfer') {
|
|
36
|
+
if (method === 'GET' && segments[1] === 'endpoints' && segments.length === 2)
|
|
37
|
+
return sendJson(res, 200, runtime.files.endpoints());
|
|
38
|
+
if (method === 'GET' && segments[1] === 'directory' && segments.length === 2) {
|
|
39
|
+
const endpointId = requireText(url.searchParams.get('endpointId'), 'endpointId', 110);
|
|
40
|
+
const paneId = requireText(url.searchParams.get('paneId'), 'paneId', 100);
|
|
41
|
+
return sendJson(res, 200, await runtime.fileSessions.run(paneId, endpointId, session => session.list(url.searchParams.get('path') ?? session.endpoint.initialPath)));
|
|
42
|
+
}
|
|
43
|
+
if (method === 'POST' && segments[1] === 'delete' && segments.length === 2) {
|
|
44
|
+
requireMutationHeader(req);
|
|
45
|
+
const request = parseFileDeleteRequest(await readObject(req));
|
|
46
|
+
await runtime.fileSessions.run(request.paneId, request.endpointId, async (session) => {
|
|
47
|
+
const directory = await session.list(request.directory);
|
|
48
|
+
const available = new Set(directory.entries.map(entry => entry.path));
|
|
49
|
+
for (const path of request.paths)
|
|
50
|
+
if (!available.has(path))
|
|
51
|
+
throw httpError(404, `remote path ${path} is not a direct child of ${directory.path}`);
|
|
52
|
+
for (const path of request.paths)
|
|
53
|
+
await session.remove(path, true);
|
|
54
|
+
});
|
|
55
|
+
return sendJson(res, 204, undefined);
|
|
56
|
+
}
|
|
57
|
+
if (segments[1] === 'jobs') {
|
|
58
|
+
if (method === 'GET' && segments.length === 2)
|
|
59
|
+
return sendJson(res, 200, runtime.transfers.list());
|
|
60
|
+
if (method === 'POST' && segments.length === 2) {
|
|
61
|
+
requireMutationHeader(req);
|
|
62
|
+
const body = await readObject(req);
|
|
63
|
+
return sendJson(res, 202, runtime.transfers.start('ui', parseTransferRequest(body)));
|
|
64
|
+
}
|
|
65
|
+
const jobId = segments[2];
|
|
66
|
+
if (jobId !== undefined && method === 'GET' && segments.length === 3)
|
|
67
|
+
return sendJson(res, 200, runtime.transfers.get(jobId));
|
|
68
|
+
if (jobId !== undefined && method === 'DELETE' && segments.length === 3) {
|
|
69
|
+
requireMutationHeader(req);
|
|
70
|
+
runtime.transfers.cancel(jobId);
|
|
71
|
+
return sendJson(res, 204, undefined);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (segments[0] === 'ftp-profiles') {
|
|
76
|
+
if (method === 'GET' && segments.length === 1)
|
|
77
|
+
return sendJson(res, 200, await ftpProfileViews(runtime));
|
|
78
|
+
if (method === 'POST' && segments[1] === 'test-draft' && segments.length === 2) {
|
|
79
|
+
requireMutationHeader(req);
|
|
80
|
+
const body = await readObject(req);
|
|
81
|
+
const draft = normalizeFtpProfileDraft(body.profile);
|
|
82
|
+
const id = body.profileId === undefined ? createId('ftp-preview') : requiredFtpProfile(runtime.store, requireText(body.profileId, 'profileId', 100)).id;
|
|
83
|
+
const previous = body.profileId === undefined ? {} : await runtime.credentials.readFtp(id);
|
|
84
|
+
const secrets = { ...previous, ...normalizeSecrets(body.secrets) };
|
|
85
|
+
if (!secrets.password && draft.credentialId === undefined)
|
|
86
|
+
throw httpError(400, 'FTP password is required');
|
|
87
|
+
const credentialEntry = draft.credentialId === undefined ? undefined : requiredPasswordCredential(runtime.store, draft.credentialId);
|
|
88
|
+
const password = credentialEntry === undefined ? secrets.password : (await runtime.credentials.readEntry(credentialEntry.id)).password;
|
|
89
|
+
if (!password)
|
|
90
|
+
throw httpError(400, 'FTP password credential is not configured');
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const profile = { ...draft, id, username: credentialEntry?.username ?? draft.username, port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: now, updatedAt: now };
|
|
93
|
+
const session = await connectFtpProfile(profile, password, runtime.dialer);
|
|
94
|
+
try {
|
|
95
|
+
await session.list(profile.initialPath);
|
|
96
|
+
return sendJson(res, 200, { ok: true });
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
session.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (method === 'POST' && segments.length === 1) {
|
|
103
|
+
requireMutationHeader(req);
|
|
104
|
+
const body = await readObject(req);
|
|
105
|
+
const draft = normalizeFtpProfileDraft(body.profile);
|
|
106
|
+
if (draft.credentialId !== undefined)
|
|
107
|
+
requiredPasswordCredential(runtime.store, draft.credentialId);
|
|
108
|
+
const secrets = normalizeSecrets(body.secrets);
|
|
109
|
+
if (draft.credentialId === undefined && !secrets.password)
|
|
110
|
+
throw httpError(400, 'FTP password is required');
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const profile = { ...draft, id: createId('ftp'), port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: now, updatedAt: now };
|
|
113
|
+
if (profile.credentialId === undefined)
|
|
114
|
+
await runtime.credentials.replaceFtp(profile.id, { password: secrets.password });
|
|
115
|
+
try {
|
|
116
|
+
await runtime.store.update(state => { state.ftpProfiles.push(profile); });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (profile.credentialId === undefined)
|
|
120
|
+
await runtime.credentials.deleteFtp(profile.id).catch(() => { });
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
return sendJson(res, 201, await ftpProfileView(runtime, profile));
|
|
124
|
+
}
|
|
125
|
+
const id = segments[1];
|
|
126
|
+
if (id !== undefined && method === 'PUT' && segments.length === 2) {
|
|
127
|
+
requireMutationHeader(req);
|
|
128
|
+
const previous = requiredFtpProfile(runtime.store, id);
|
|
129
|
+
const body = await readObject(req);
|
|
130
|
+
const draft = normalizeFtpProfileDraft(body.profile);
|
|
131
|
+
if (draft.credentialId !== undefined)
|
|
132
|
+
requiredPasswordCredential(runtime.store, draft.credentialId);
|
|
133
|
+
const secrets = normalizeSecrets(body.secrets);
|
|
134
|
+
const next = { ...previous, ...draft, id, port: draft.port ?? defaultFtpPort(draft.protocol), proxy: draft.proxy ?? { type: 'none' }, initialPath: draft.initialPath ?? '/', connectTimeoutMs: draft.connectTimeoutMs ?? 15_000, createdAt: previous.createdAt, updatedAt: Date.now() };
|
|
135
|
+
if (draft.group === undefined)
|
|
136
|
+
delete next.group;
|
|
137
|
+
if (draft.tlsServerName === undefined)
|
|
138
|
+
delete next.tlsServerName;
|
|
139
|
+
const oldSecrets = await runtime.credentials.readFtp(id);
|
|
140
|
+
if (next.credentialId === undefined && !secrets.password && !oldSecrets.password)
|
|
141
|
+
throw httpError(400, 'FTP password is required when switching to connection-specific credentials');
|
|
142
|
+
if (next.credentialId === undefined && secrets.password)
|
|
143
|
+
await runtime.credentials.writeFtp(id, { password: secrets.password });
|
|
144
|
+
try {
|
|
145
|
+
await runtime.store.update(state => { state.ftpProfiles = state.ftpProfiles.map(profile => profile.id === id ? next : profile); });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
await runtime.credentials.replaceFtp(id, oldSecrets).catch(() => { });
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
if (next.credentialId !== undefined && previous.credentialId === undefined)
|
|
152
|
+
await runtime.credentials.deleteFtp(id).catch(() => { });
|
|
153
|
+
return sendJson(res, 200, await ftpProfileView(runtime, next));
|
|
154
|
+
}
|
|
155
|
+
if (id !== undefined && method === 'DELETE' && segments.length === 2) {
|
|
156
|
+
requireMutationHeader(req);
|
|
157
|
+
requiredFtpProfile(runtime.store, id);
|
|
158
|
+
await runtime.store.update(state => {
|
|
159
|
+
state.ftpProfiles = state.ftpProfiles.filter(profile => profile.id !== id);
|
|
160
|
+
state.injections = state.injections.map(injection => ({ ...injection, fileEndpointIds: injection.fileEndpointIds.filter(endpointId => endpointId !== `ftp:${id}`) }));
|
|
161
|
+
});
|
|
162
|
+
await runtime.credentials.deleteFtp(id);
|
|
163
|
+
return sendJson(res, 204, undefined);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
34
166
|
if (segments[0] === 'vault') {
|
|
35
167
|
if (method === 'GET' && segments.length === 1)
|
|
36
168
|
return sendJson(res, 200, await credentialEntryViews(runtime));
|
|
@@ -76,9 +208,9 @@ async function dispatch(req, res, prefix, runtime) {
|
|
|
76
208
|
if (id !== undefined && method === 'DELETE' && segments.length === 2) {
|
|
77
209
|
requireMutationHeader(req);
|
|
78
210
|
requiredCredentialEntry(runtime.store, id);
|
|
79
|
-
const references = runtime.store.profiles().filter(profile => profile.credentialId === id);
|
|
80
|
-
if (references
|
|
81
|
-
throw httpError(409, `credential entry is used by ${references
|
|
211
|
+
const references = runtime.store.profiles().filter(profile => profile.credentialId === id).length + runtime.store.ftpProfiles().filter(profile => profile.credentialId === id).length;
|
|
212
|
+
if (references > 0)
|
|
213
|
+
throw httpError(409, `credential entry is used by ${references} connection(s)`);
|
|
82
214
|
await runtime.store.update(state => { state.credentialEntries = state.credentialEntries.filter(entry => entry.id !== id); });
|
|
83
215
|
await runtime.credentials.deleteEntry(id);
|
|
84
216
|
return sendJson(res, 204, undefined);
|
|
@@ -127,9 +259,9 @@ async function dispatch(req, res, prefix, runtime) {
|
|
|
127
259
|
if (id !== undefined && method === 'DELETE' && segments.length === 2) {
|
|
128
260
|
requireMutationHeader(req);
|
|
129
261
|
requiredProxyEntry(runtime.store, id);
|
|
130
|
-
const references = runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id);
|
|
131
|
-
if (references
|
|
132
|
-
throw httpError(409, `proxy entry is used by ${references
|
|
262
|
+
const references = runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id).length + runtime.store.ftpProfiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === id).length;
|
|
263
|
+
if (references > 0)
|
|
264
|
+
throw httpError(409, `proxy entry is used by ${references} connection(s)`);
|
|
133
265
|
await runtime.store.update(state => { state.proxyEntries = state.proxyEntries.filter(entry => entry.id !== id); });
|
|
134
266
|
await runtime.credentials.deleteProxyEntry(id);
|
|
135
267
|
return sendJson(res, 204, undefined);
|
|
@@ -284,7 +416,7 @@ async function dispatch(req, res, prefix, runtime) {
|
|
|
284
416
|
state.injections = state.injections.map(item => {
|
|
285
417
|
const { [id]: _removed, ...workingDirectories } = item.workingDirectories;
|
|
286
418
|
const { [id]: _removedProject, ...workingProjectIds } = item.workingProjectIds;
|
|
287
|
-
return { ...item, profileIds: item.profileIds.filter(profileId => profileId !== id), workingDirectories, workingProjectIds };
|
|
419
|
+
return { ...item, profileIds: item.profileIds.filter(profileId => profileId !== id), fileEndpointIds: (item.fileEndpointIds ?? []).filter(endpointId => endpointId !== `sftp:${id}`), workingDirectories, workingProjectIds };
|
|
288
420
|
});
|
|
289
421
|
});
|
|
290
422
|
await runtime.credentials.delete(id);
|
|
@@ -374,14 +506,21 @@ async function dispatch(req, res, prefix, runtime) {
|
|
|
374
506
|
if (sessionId !== undefined && method === 'PUT' && segments.length === 2) {
|
|
375
507
|
requireMutationHeader(req);
|
|
376
508
|
const body = await readObject(req);
|
|
509
|
+
const previous = runtime.store.injection(sessionId);
|
|
377
510
|
const profileIds = parseProfileIds(body.profileIds, runtime.store);
|
|
511
|
+
const fileEndpointIds = body.fileEndpointIds === undefined ? previous?.fileEndpointIds ?? [] : parseFileEndpointIds(body.fileEndpointIds, runtime.files);
|
|
512
|
+
const filePermission = body.filePermission === undefined ? previous?.filePermission ?? 'browse' : body.filePermission === 'browse' ? 'browse' : body.filePermission === 'transfer' ? 'transfer' : undefined;
|
|
513
|
+
if (filePermission === undefined)
|
|
514
|
+
throw httpError(400, 'filePermission must be browse or transfer');
|
|
378
515
|
const permission = body.permission === 'exec' ? 'exec' : body.permission === 'terminal' ? 'terminal' : undefined;
|
|
379
516
|
if (permission === undefined)
|
|
380
517
|
throw httpError(400, 'permission must be exec or terminal');
|
|
381
|
-
const previous = runtime.store.injection(sessionId);
|
|
382
518
|
const workingDirectories = parseWorkingDirectories(body.workingDirectories, profileIds);
|
|
383
519
|
const workingProjectIds = parseWorkingProjectIds(body.workingProjectIds, profileIds, runtime.store);
|
|
384
|
-
const injection = {
|
|
520
|
+
const injection = {
|
|
521
|
+
sessionId, profileIds, fileEndpointIds, filePermission, requireFileApproval: body.requireFileApproval === undefined ? previous?.requireFileApproval ?? true : body.requireFileApproval !== false,
|
|
522
|
+
permission, requireCommandApproval: body.requireCommandApproval !== false, workingDirectories, workingProjectIds, updatedAt: Date.now(),
|
|
523
|
+
};
|
|
385
524
|
await runtime.store.update(state => { state.injections = [...state.injections.filter(item => item.sessionId !== sessionId), injection]; });
|
|
386
525
|
const revoked = previous?.profileIds.filter(profileId => !profileIds.includes(profileId)) ?? [];
|
|
387
526
|
if (permission !== 'terminal')
|
|
@@ -606,14 +745,24 @@ async function credentialEntryViews(runtime) {
|
|
|
606
745
|
async function credentialEntryView(runtime, entry) {
|
|
607
746
|
const credential = await runtime.credentials.describeEntry(entry.id);
|
|
608
747
|
const requiredField = entry.authType === 'password' ? 'password' : 'privateKey';
|
|
609
|
-
return { ...entry, credential: { ...credential, configured: credential.fields.includes(requiredField) }, references: runtime.store.profiles().filter(profile => profile.credentialId === entry.id).length };
|
|
748
|
+
return { ...entry, credential: { ...credential, configured: credential.fields.includes(requiredField) }, references: runtime.store.profiles().filter(profile => profile.credentialId === entry.id).length + runtime.store.ftpProfiles().filter(profile => profile.credentialId === entry.id).length };
|
|
610
749
|
}
|
|
611
750
|
async function proxyEntryViews(runtime) {
|
|
612
751
|
return Promise.all(runtime.store.proxyEntries().map(entry => proxyEntryView(runtime, entry)));
|
|
613
752
|
}
|
|
614
753
|
async function proxyEntryView(runtime, entry) {
|
|
615
754
|
const credential = await runtime.credentials.describeProxyEntry(entry.id);
|
|
616
|
-
return { ...entry, credential, references: runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length };
|
|
755
|
+
return { ...entry, credential, references: runtime.store.profiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length + runtime.store.ftpProfiles().filter(profile => profile.proxy.type === 'saved' && profile.proxy.proxyId === entry.id).length };
|
|
756
|
+
}
|
|
757
|
+
async function ftpProfileViews(runtime) { return Promise.all(runtime.store.ftpProfiles().map(profile => ftpProfileView(runtime, profile))); }
|
|
758
|
+
async function ftpProfileView(runtime, profile) {
|
|
759
|
+
if (profile.credentialId !== undefined) {
|
|
760
|
+
const entry = requiredPasswordCredential(runtime.store, profile.credentialId);
|
|
761
|
+
const credential = await runtime.credentials.describeEntry(entry.id);
|
|
762
|
+
return { ...profile, username: entry.username, credential: { ...credential, configured: credential.fields.includes('password'), source: 'vault', entryId: entry.id, entryName: entry.name } };
|
|
763
|
+
}
|
|
764
|
+
const credential = await runtime.credentials.describeFtp(profile.id);
|
|
765
|
+
return { ...profile, credential: { ...credential, configured: credential.fields.includes('password'), source: 'profile' } };
|
|
617
766
|
}
|
|
618
767
|
function requiredProfile(store, id) {
|
|
619
768
|
const profile = store.profile(id);
|
|
@@ -621,6 +770,18 @@ function requiredProfile(store, id) {
|
|
|
621
770
|
throw httpError(404, 'SSH profile was not found');
|
|
622
771
|
return profile;
|
|
623
772
|
}
|
|
773
|
+
function requiredFtpProfile(store, id) {
|
|
774
|
+
const profile = store.ftpProfile(id);
|
|
775
|
+
if (profile === undefined)
|
|
776
|
+
throw httpError(404, 'FTP profile was not found');
|
|
777
|
+
return profile;
|
|
778
|
+
}
|
|
779
|
+
function requiredPasswordCredential(store, id) {
|
|
780
|
+
const entry = requiredCredentialEntry(store, id);
|
|
781
|
+
if (entry.authType !== 'password')
|
|
782
|
+
throw httpError(400, 'FTP connections require a password credential');
|
|
783
|
+
return entry;
|
|
784
|
+
}
|
|
624
785
|
function requiredRemoteProject(store, id, profileId) {
|
|
625
786
|
const project = store.remoteProject(id);
|
|
626
787
|
if (project === undefined || project.profileId !== profileId)
|
|
@@ -703,6 +864,50 @@ function parseWorkingProjectIds(value, profileIds, store) {
|
|
|
703
864
|
}
|
|
704
865
|
return result;
|
|
705
866
|
}
|
|
867
|
+
function parseFileEndpointIds(value, files) {
|
|
868
|
+
if (value === undefined)
|
|
869
|
+
return [];
|
|
870
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
871
|
+
throw httpError(400, 'fileEndpointIds must be an array');
|
|
872
|
+
return [...new Set(value.map(item => requireText(item, 'fileEndpointId', 110)))].map(id => {
|
|
873
|
+
if (files.endpoint(id) === undefined)
|
|
874
|
+
throw httpError(404, `file endpoint ${id} was not found`);
|
|
875
|
+
return id;
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
function parseTransferRequest(value) {
|
|
879
|
+
const conflict = value.conflictPolicy;
|
|
880
|
+
if (conflict !== 'fail' && conflict !== 'skip' && conflict !== 'overwrite' && conflict !== 'rename')
|
|
881
|
+
throw httpError(400, 'invalid conflictPolicy');
|
|
882
|
+
if (!Array.isArray(value.sourcePaths))
|
|
883
|
+
throw httpError(400, 'sourcePaths must be an array');
|
|
884
|
+
return {
|
|
885
|
+
sourceEndpointId: requireText(value.sourceEndpointId, 'sourceEndpointId', 110),
|
|
886
|
+
sourcePaths: value.sourcePaths.map(path => requireRawText(path, 'source path', 4096)),
|
|
887
|
+
destinationEndpointId: requireText(value.destinationEndpointId, 'destinationEndpointId', 110),
|
|
888
|
+
destinationDirectory: requireRawText(value.destinationDirectory, 'destinationDirectory', 4096),
|
|
889
|
+
conflictPolicy: conflict,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function parseFileDeleteRequest(value) {
|
|
893
|
+
if (!Array.isArray(value.paths) || value.paths.length < 1 || value.paths.length > 100)
|
|
894
|
+
throw httpError(400, 'paths must contain 1-100 entries');
|
|
895
|
+
const paths = [...new Set(value.paths.map(path => requireRawText(path, 'remote path', 4096)))];
|
|
896
|
+
for (const path of paths)
|
|
897
|
+
assertDeletableRemotePath(path);
|
|
898
|
+
return {
|
|
899
|
+
paneId: requireText(value.paneId, 'paneId', 100),
|
|
900
|
+
endpointId: requireText(value.endpointId, 'endpointId', 110),
|
|
901
|
+
directory: requireRawText(value.directory, 'directory', 4096),
|
|
902
|
+
paths,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function assertDeletableRemotePath(value) {
|
|
906
|
+
const normalized = value.trim().replaceAll('\\', '/').replace(/\/+$/, '') || '/';
|
|
907
|
+
if (normalized === '/' || normalized === '.' || normalized === '~' || /^[A-Za-z]:$/.test(normalized))
|
|
908
|
+
throw httpError(400, 'refusing to delete a remote filesystem root');
|
|
909
|
+
}
|
|
910
|
+
function defaultFtpPort(protocol) { return protocol === 'ftps-implicit' ? 990 : 21; }
|
|
706
911
|
function createId(prefix) { return `${prefix}-${randomBytes(10).toString('hex')}`; }
|
|
707
912
|
function assertSameOrigin(req) {
|
|
708
913
|
const origin = req.headers.origin;
|