ot-cli 0.2.1__py3-none-any.whl
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.
- openterminal/__init__.py +3 -0
- openterminal/agent/__init__.py +39 -0
- openterminal/agent/backend.py +180 -0
- openterminal/agent/build.py +115 -0
- openterminal/agent/middleware.py +111 -0
- openterminal/agent/model.py +41 -0
- openterminal/agent/prompt.py +63 -0
- openterminal/agent/runner.py +340 -0
- openterminal/agent/sysprobe.py +164 -0
- openterminal/agent/token_est.py +49 -0
- openterminal/app.py +52 -0
- openterminal/approval.py +7 -0
- openterminal/backend.py +3 -0
- openterminal/banner.py +7 -0
- openterminal/cli.py +426 -0
- openterminal/cmdset.py +7 -0
- openterminal/config.py +322 -0
- openterminal/connections/__init__.py +20 -0
- openterminal/connections/db.py +240 -0
- openterminal/connections/models.py +289 -0
- openterminal/connections/secrets.py +68 -0
- openterminal/connections_db.py +6 -0
- openterminal/core/__init__.py +41 -0
- openterminal/core/ai_flow.py +781 -0
- openterminal/core/auth.py +499 -0
- openterminal/core/cmdset_runner.py +352 -0
- openterminal/core/exec_events.py +650 -0
- openterminal/core/history.py +79 -0
- openterminal/core/hook_gate.py +415 -0
- openterminal/core/messages.py +193 -0
- openterminal/core/nested_ssh.py +178 -0
- openterminal/core/pipeline.py +1187 -0
- openterminal/core/pump.py +190 -0
- openterminal/core/transcript.py +26 -0
- openterminal/frontend/__init__.py +21 -0
- openterminal/frontend/approval.py +40 -0
- openterminal/frontend/banner.py +40 -0
- openterminal/frontend/headless.py +290 -0
- openterminal/frontend/render.py +41 -0
- openterminal/frontend/taskview.py +184 -0
- openterminal/frontend/term.py +1417 -0
- openterminal/headless.py +7 -0
- openterminal/history_db.py +7 -0
- openterminal/local_pty.py +3 -0
- openterminal/local_win.py +3 -0
- openterminal/policy.py +362 -0
- openterminal/render.py +7 -0
- openterminal/secrets_store.py +5 -0
- openterminal/session/__init__.py +17 -0
- openterminal/session/base.py +328 -0
- openterminal/session/local_posix.py +184 -0
- openterminal/session/local_win.py +173 -0
- openterminal/session/ssh.py +321 -0
- openterminal/shell/__init__.py +17 -0
- openterminal/shell/cmdset.py +49 -0
- openterminal/shell/integration.py +1298 -0
- openterminal/shell_integration.py +7 -0
- openterminal/shell_session.py +8 -0
- openterminal/skills/__init__.py +45 -0
- openterminal/skills/markdown-tables/SKILL.md +247 -0
- openterminal/ssh_pty.py +6 -0
- openterminal/sysprobe.py +7 -0
- openterminal/taskview.py +7 -0
- openterminal/term_frontend.py +7 -0
- openterminal/token_est.py +3 -0
- openterminal/transcript.py +7 -0
- openterminal/web/__init__.py +1 -0
- openterminal/web/files_api.py +789 -0
- openterminal/web/frontend/index.html +37 -0
- openterminal/web/frontend/static/app.js +3632 -0
- openterminal/web/frontend/static/block_stream.js +274 -0
- openterminal/web/frontend/static/block_view.js +383 -0
- openterminal/web/frontend/static/files_panel.js +782 -0
- openterminal/web/frontend/static/md_lite.js +127 -0
- openterminal/web/frontend/static/modal.js +122 -0
- openterminal/web/frontend/static/pad_ledger.js +46 -0
- openterminal/web/frontend/static/style.css +915 -0
- openterminal/web/frontend/static/ui/ui.css +1 -0
- openterminal/web/frontend/static/ui/ui.js +6292 -0
- openterminal/web/frontend/vendor/addon-fit.js +2 -0
- openterminal/web/frontend/vendor/xterm.css +8 -0
- openterminal/web/frontend/vendor/xterm.js +8 -0
- openterminal/web/protocol.py +41 -0
- openterminal/web/server.py +322 -0
- openterminal/web/worker.py +62 -0
- ot_cli-0.2.1.dist-info/METADATA +439 -0
- ot_cli-0.2.1.dist-info/RECORD +91 -0
- ot_cli-0.2.1.dist-info/WHEEL +5 -0
- ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
- ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- ot_cli-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
"""Web 文件面板:远程目录浏览/读写端点。
|
|
2
|
+
|
|
3
|
+
分层(本模块只做字节搬运,不碰终端输出通道):
|
|
4
|
+
|
|
5
|
+
1. **通道** `_PanelConn`——面板自持的 SSH 连接:**直连**目标主机,
|
|
6
|
+
不经终端会话的连接、不碰 PTY,终端零变化;终端断开面板照常工作。
|
|
7
|
+
连接目标由用户在登录弹框里给(IP/端口/用户名/密码),默认值取自
|
|
8
|
+
终端所在的那台机器(`files_identity`),但连接本身与终端无关。
|
|
9
|
+
2. **路径安全** `_clean_name``/``_join``——写入端点的文件名一律剥掉
|
|
10
|
+
路径分隔符与 ``..``,绝不让上传的 ``Content-Disposition`` 文件名
|
|
11
|
+
把落点拖出目标目录(真机取证:``../../tmp/x`` 曾直接落出目录)。
|
|
12
|
+
3. **端点**——统一经 ``_channel`` 取通道,失败映射成带 ``code`` 的可读
|
|
13
|
+
JSON;未连接是 503/retry 而不是 500,前端据此自动等就绪后重列。
|
|
14
|
+
|
|
15
|
+
读写语义上的两个硬约束(都是真机踩过的):
|
|
16
|
+
|
|
17
|
+
* 新建文件走 ``"x"``(FXF_EXCL)——``"w"`` 会把同名旧文件截成 0 字节;
|
|
18
|
+
* 上传/保存一律「临时文件 + 改名」原子替换:``open(path,'wb')`` 一打开
|
|
19
|
+
就把旧文件截成 0 字节,中途失败时用户原文件已经没了;改成先写
|
|
20
|
+
``*.otpart`` 再改名上去,失败只清临时文件,原文件原样保留。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import base64
|
|
27
|
+
import hashlib
|
|
28
|
+
import posixpath
|
|
29
|
+
import stat as stat_mod
|
|
30
|
+
from contextlib import suppress
|
|
31
|
+
from typing import Any
|
|
32
|
+
from urllib.parse import quote as urlquote
|
|
33
|
+
|
|
34
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
35
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
36
|
+
|
|
37
|
+
_CHUNK = 128 * 1024
|
|
38
|
+
# 在线编辑的读取/写入上限:编辑器走浏览器内存,超限拒绝并提示用下载
|
|
39
|
+
_EDIT_LIMIT = 2 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- 错误信封 -------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
class PanelError(HTTPException):
|
|
45
|
+
"""面板错误:detail 给人看,code 给前端分派,retry 说值不值得自动重试。"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, status: int, code: str, detail: str,
|
|
48
|
+
*, retry: bool = False, **extra: Any) -> None:
|
|
49
|
+
super().__init__(status, detail)
|
|
50
|
+
self.code = code
|
|
51
|
+
self.retry = retry
|
|
52
|
+
self.extra = extra
|
|
53
|
+
|
|
54
|
+
def payload(self) -> dict:
|
|
55
|
+
return {"detail": self.detail, "code": self.code,
|
|
56
|
+
"retry": self.retry, **self.extra}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- 面板连接:异常与连接建立 --------------------------------------------
|
|
60
|
+
|
|
61
|
+
class ConnError(Exception):
|
|
62
|
+
"""面板连接的可读失败基类(str(exc) 直接作为接口错误文案)。
|
|
63
|
+
|
|
64
|
+
**不能叫 PanelError**:HTTP 错误信封已占了那个名字,同名会让后定义
|
|
65
|
+
的类覆盖前一个,路由里的 ``PanelError(status, code, detail, retry=…)``
|
|
66
|
+
全部走错类(TypeError/异常处理器注册错对象)。连接层的异常由
|
|
67
|
+
``connect_panel`` 抛出、路由映射成 HTTP 状态,与信封分工不同。
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class HostKeyChanged(ConnError):
|
|
72
|
+
"""known_hosts 已有记录但密钥不符——硬失败,绝不静默覆盖。"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AuthFailed(ConnError):
|
|
76
|
+
"""目标主机认证失败(密码/密钥均不可用)。"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class HostKeyUnknown(ConnError):
|
|
80
|
+
"""未知主机密钥,等用户确认(TOFU)。携带指纹供前端展示。"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, host: str, algorithm: str, fingerprint: str) -> None:
|
|
83
|
+
super().__init__(f"主机 {host} 密钥未知")
|
|
84
|
+
self.host = host
|
|
85
|
+
self.algorithm = algorithm
|
|
86
|
+
self.fingerprint = fingerprint
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def detail(self) -> str:
|
|
90
|
+
return (f"主机 {self.host} 的密钥未知({self.algorithm} "
|
|
91
|
+
f"SHA256:{self.fingerprint}),请确认后信任")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def connect_panel(*, host: str, port: int | None,
|
|
95
|
+
user: str | None, password: str | None,
|
|
96
|
+
confirm_host_key: bool = False):
|
|
97
|
+
"""直连目标主机并打开 SFTP 客户端,返回 (conn, sftp)。
|
|
98
|
+
|
|
99
|
+
**不走隧道、不碰终端**:面板自带一条 SSH 连接,与终端会话无关——
|
|
100
|
+
这是「文件面板和终端完全独立」的落地点。代价是要求 web 服务所在
|
|
101
|
+
机器能网络直达 ``host``(用户环境实测真机直连可达)。
|
|
102
|
+
|
|
103
|
+
主机校验语义对齐主连接的 TOFU(ssh.py._trust_unknown_host):
|
|
104
|
+
known_hosts 已有记录但密钥不符 → HostKeyChanged(硬失败);未知主机
|
|
105
|
+
→ 未确认时抛 HostKeyUnknown(携指纹交前端确认),确认后信任并写入
|
|
106
|
+
known_hosts。不读 ~/.ssh/config:面板是独立通道,避免用户 config
|
|
107
|
+
里的 ProxyCommand/别名把连接悄悄改道。
|
|
108
|
+
"""
|
|
109
|
+
import asyncssh
|
|
110
|
+
from ..session.ssh import (
|
|
111
|
+
format_known_host_line, known_hosts_has_entry, known_hosts_path)
|
|
112
|
+
|
|
113
|
+
kwargs: dict = {"keepalive_interval": 30}
|
|
114
|
+
if user:
|
|
115
|
+
kwargs["username"] = user
|
|
116
|
+
if port:
|
|
117
|
+
kwargs["port"] = port
|
|
118
|
+
if password:
|
|
119
|
+
kwargs["password"] = password
|
|
120
|
+
try:
|
|
121
|
+
conn = await asyncssh.connect(host, **kwargs)
|
|
122
|
+
except asyncssh.HostKeyNotVerifiable:
|
|
123
|
+
kh = known_hosts_path()
|
|
124
|
+
if known_hosts_has_entry(kh, host, port):
|
|
125
|
+
raise HostKeyChanged(
|
|
126
|
+
f"主机 {host} 密钥与 known_hosts 记录不符"
|
|
127
|
+
"(可能是重装系统或中间人),请人工核实") from None
|
|
128
|
+
# 未知主机:摸一次服务器公钥取指纹(连接可复用)。
|
|
129
|
+
# 探针要**单独**收异常:它跑在 HostKeyNotVerifiable 的 except 块里,
|
|
130
|
+
# PermissionDenied 与之是兄弟类,外层 except 收不住(Python 语义),
|
|
131
|
+
# 会一路漏到路由的 except Exception 变成 502 而不是 403 auth_failed。
|
|
132
|
+
try:
|
|
133
|
+
pconn = await asyncssh.connect(host, **dict(kwargs,
|
|
134
|
+
known_hosts=None))
|
|
135
|
+
except asyncssh.PermissionDenied:
|
|
136
|
+
raise AuthFailed(
|
|
137
|
+
"认证失败:用户名或密码错误(或密钥不可用)") from None
|
|
138
|
+
try:
|
|
139
|
+
key = pconn.get_server_host_key()
|
|
140
|
+
algo = (key.algorithm.decode() if isinstance(key.algorithm, bytes)
|
|
141
|
+
else str(key.algorithm))
|
|
142
|
+
fp = base64.b64encode(
|
|
143
|
+
hashlib.sha256(key.public_data).digest()).decode().strip("=")
|
|
144
|
+
if not confirm_host_key:
|
|
145
|
+
raise HostKeyUnknown(host, algo, fp)
|
|
146
|
+
kh.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
with kh.open("a", encoding="utf-8") as f:
|
|
148
|
+
f.write(format_known_host_line(host, port, key))
|
|
149
|
+
except BaseException:
|
|
150
|
+
pconn.close() # 未确认/落盘失败都不泄漏探针连接
|
|
151
|
+
raise
|
|
152
|
+
conn = pconn # 确认后的连接直接复用
|
|
153
|
+
except asyncssh.PermissionDenied:
|
|
154
|
+
raise AuthFailed("认证失败:用户名或密码错误(或密钥不可用)") from None
|
|
155
|
+
sftp = await conn.start_sftp_client()
|
|
156
|
+
return conn, sftp
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _PanelConn:
|
|
160
|
+
"""面板自持的 SSH 连接:SFTP 客户端懒缓存,连接关闭才重建。
|
|
161
|
+
|
|
162
|
+
凭据(含密码)留在实例里供自动重连用——面板不跨进程存活,keyring
|
|
163
|
+
只负责「首次不弹框就连上」,掉线重连用内存里的密码。
|
|
164
|
+
|
|
165
|
+
``need_password`` 是认证失败闩锁:密码错了就上闩,后续请求**不再拿
|
|
166
|
+
旧密码去撞 sshd**(反复失败可能触发服务器的锁定策略),直接报
|
|
167
|
+
auth_failed 让前端弹框换密码;用户重新连上才清闩。
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self) -> None:
|
|
171
|
+
self.host = ""
|
|
172
|
+
self.port: int | None = None
|
|
173
|
+
self.user: str | None = None
|
|
174
|
+
self.password: str | None = None
|
|
175
|
+
self.conn = None # asyncssh SSHClientConnection
|
|
176
|
+
self.sftp_client = None # asyncssh SFTPClient
|
|
177
|
+
self.lock = asyncio.Lock()
|
|
178
|
+
self.need_password = False # 认证失败闩锁
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def alive(self) -> bool:
|
|
182
|
+
return (self.conn is not None and self.sftp_client is not None
|
|
183
|
+
and not self.conn.is_closed())
|
|
184
|
+
|
|
185
|
+
def teardown(self) -> None:
|
|
186
|
+
if self.sftp_client is not None:
|
|
187
|
+
with suppress(Exception):
|
|
188
|
+
self.sftp_client.exit()
|
|
189
|
+
self.sftp_client = None
|
|
190
|
+
if self.conn is not None:
|
|
191
|
+
with suppress(Exception):
|
|
192
|
+
self.conn.close()
|
|
193
|
+
self.conn = None
|
|
194
|
+
|
|
195
|
+
def forget_password(self) -> None:
|
|
196
|
+
"""凭据不可信时丢掉明文(认证失败/主动断开)。"""
|
|
197
|
+
self.password = None
|
|
198
|
+
|
|
199
|
+
async def sftp(self):
|
|
200
|
+
"""可用的 SFTP 客户端:连接关闭时用已存凭据重建。
|
|
201
|
+
|
|
202
|
+
重建走默认 known_hosts 校验(首次连接确认后已落盘),不自动信任
|
|
203
|
+
新指纹——重建是同一台主机,密钥变了就该报错而不是悄悄接受。
|
|
204
|
+
|
|
205
|
+
失败分两类:``AuthFailed`` 上闩(不可重试,等换密码);网络类
|
|
206
|
+
异常原样抛出,由 ``_channel`` 映射成 503/retry 供前端自动退避。
|
|
207
|
+
"""
|
|
208
|
+
if self.alive:
|
|
209
|
+
return self.sftp_client
|
|
210
|
+
async with self.lock:
|
|
211
|
+
if self.alive:
|
|
212
|
+
return self.sftp_client
|
|
213
|
+
if self.need_password:
|
|
214
|
+
raise AuthFailed("认证失败:请重新输入密码")
|
|
215
|
+
self.teardown()
|
|
216
|
+
try:
|
|
217
|
+
self.conn, self.sftp_client = await connect_panel(
|
|
218
|
+
host=self.host, port=self.port, user=self.user,
|
|
219
|
+
password=self.password)
|
|
220
|
+
except AuthFailed:
|
|
221
|
+
self.forget_password()
|
|
222
|
+
self.need_password = True
|
|
223
|
+
raise
|
|
224
|
+
return self.sftp_client
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def close_panel(worker) -> None:
|
|
229
|
+
"""释放面板连接(幂等:可从任意退出路径安全调用)。
|
|
230
|
+
|
|
231
|
+
挂在 ``TabWorker`` 的 ``_disposables`` 钩子上,``close()`` 统一触发——
|
|
232
|
+
旧实现只在 ``close_tab`` 一处调用,断线回收与 app 关闭两条路径都漏了,
|
|
233
|
+
SSH 连接一直挂着不放。钩子结构让漏接在类型上不可能。
|
|
234
|
+
"""
|
|
235
|
+
c = getattr(worker, "_panel_files", None)
|
|
236
|
+
if c is None:
|
|
237
|
+
return
|
|
238
|
+
worker._panel_files = None
|
|
239
|
+
async with c.lock:
|
|
240
|
+
c.teardown()
|
|
241
|
+
c.forget_password()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# --- 路径安全 -------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _clean_name(name: str) -> str:
|
|
247
|
+
"""文件名消毒:剥掉一切目录成分,只留最后一段。
|
|
248
|
+
|
|
249
|
+
上传的 ``Content-Disposition`` 文件名由客户端说了算:``../../etc/x``
|
|
250
|
+
或 ``/etc/x`` 都必须落成目标目录下的 ``x``(真机取证:未消毒时
|
|
251
|
+
``../../tmp/PWNED.txt`` 真的落到了 /tmp/PWNED.txt)。
|
|
252
|
+
"""
|
|
253
|
+
name = (name or "").replace("\\", "/")
|
|
254
|
+
name = posixpath.basename(name.rstrip("/"))
|
|
255
|
+
return "" if name in ("", ".", "..") else name
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _norm(path: str) -> str:
|
|
259
|
+
"""远端路径规范化:塌掉 ``.``/``..``,但保留用户显式给的绝对/相对语义。"""
|
|
260
|
+
p = (path or "").replace("\\", "/").strip()
|
|
261
|
+
if not p:
|
|
262
|
+
return "."
|
|
263
|
+
if p.startswith("~"): # SFTP 不做 shell 展开,~ 交给服务端原样
|
|
264
|
+
return p
|
|
265
|
+
abs_path = p.startswith("/")
|
|
266
|
+
p = posixpath.normpath(p)
|
|
267
|
+
return ("/" + p.lstrip("/")) if abs_path else p
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _join(dirname: str, name: str) -> str:
|
|
271
|
+
"""目录 + 消毒后的文件名 → 远端绝对/相对路径。"""
|
|
272
|
+
name = _clean_name(name)
|
|
273
|
+
if not name:
|
|
274
|
+
raise PanelError(400, "bad_name", "文件名不合法")
|
|
275
|
+
base = _norm(dirname)
|
|
276
|
+
return name if base == "." else posixpath.join(base, name)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _is_dir(attrs) -> bool:
|
|
280
|
+
if getattr(attrs, "permissions", None) is not None:
|
|
281
|
+
return stat_mod.S_ISDIR(attrs.permissions)
|
|
282
|
+
return getattr(attrs, "type", 0) == 2
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- 原子写 ----------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def _temp_name(remote: str) -> str:
|
|
288
|
+
import secrets
|
|
289
|
+
|
|
290
|
+
return f"{remote}.otpart-{secrets.token_hex(4)}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def _replace(sftp, tmp: str, remote: str) -> None:
|
|
294
|
+
"""临时文件改名顶替目标(POSIX rename-to-existing)。
|
|
295
|
+
|
|
296
|
+
posix-rename 是 OpenSSH 扩展;不支持的服务器退回「删目标再改名」——
|
|
297
|
+
仍比直接 open('wb') 安全:失败时临时文件还在,原文件没被截断。
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
await sftp.posix_rename(tmp, remote)
|
|
301
|
+
return
|
|
302
|
+
except Exception: # noqa: BLE001 - 扩展不可用再退
|
|
303
|
+
pass
|
|
304
|
+
with suppress(Exception):
|
|
305
|
+
await sftp.remove(remote)
|
|
306
|
+
await sftp.rename(tmp, remote)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# --- 通道 -----------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
class _Channel:
|
|
312
|
+
"""一次端点调用拿到的可用 SFTP(面板自己的直连,与终端无关)。"""
|
|
313
|
+
|
|
314
|
+
def __init__(self, sftp, worker) -> None:
|
|
315
|
+
self.sftp = sftp
|
|
316
|
+
self.worker = worker
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def files_identity(worker) -> dict:
|
|
320
|
+
"""终端所在的主机 + 以谁的身份连——**只用来给登录弹框预填默认值**。
|
|
321
|
+
|
|
322
|
+
面板的连接与终端无关,但「IP 默认是终端连的最终服务器、用户名默认是
|
|
323
|
+
终端当前用户」这条需求要从这里取。取值在 core/pipeline.py 的
|
|
324
|
+
``files_identity``:host/port 来自最内层未闭合 ssh 帧,user 优先取
|
|
325
|
+
提示符里的用户名(`sudo su -` 后是 root)。fake/旧 worker 没有该属性
|
|
326
|
+
时退回会话自身的连接目标。
|
|
327
|
+
"""
|
|
328
|
+
ident = getattr(worker, "files_identity", None)
|
|
329
|
+
if isinstance(ident, dict):
|
|
330
|
+
return ident
|
|
331
|
+
session = getattr(worker, "session", None)
|
|
332
|
+
user = (getattr(worker, "_target_user", "")
|
|
333
|
+
or getattr(session, "username", "") or "")
|
|
334
|
+
return {"host": getattr(worker, "_target_host", "") or "",
|
|
335
|
+
"port": None, "users": [user] if user else [],
|
|
336
|
+
"effective_user": user, "nested": False,
|
|
337
|
+
"mode": getattr(getattr(worker, "_target", None), "mode", "")}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _cred_port(port: int | None) -> int | None:
|
|
341
|
+
"""凭据键的端口归一化:默认端口统一记成 None(键里不写 ``:22``)。
|
|
342
|
+
|
|
343
|
+
``display_name`` 只在 port 为真值时拼 ``:port``,于是同一身份会落在
|
|
344
|
+
``user@host`` 与 ``user@host:22`` 两个键下,单键直查永远 miss——这正是
|
|
345
|
+
「记住过却仍弹框」的来源。写入归一到 None,读取走 ``load_password_alias``
|
|
346
|
+
互查,两边就对上了。非默认端口必须精确(alias 明确不互查 2222 这类)。
|
|
347
|
+
"""
|
|
348
|
+
return None if port in (None, 22) else port
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _has_password(host: str, user: str | None, port: int | None) -> bool:
|
|
352
|
+
"""凭据库里有没有该身份的密码(弹框据此提示「已记住,将自动连接」)。"""
|
|
353
|
+
if not host:
|
|
354
|
+
return False
|
|
355
|
+
from ..connections.secrets import load_password_alias
|
|
356
|
+
|
|
357
|
+
return bool(load_password_alias(host, user, _cred_port(port)))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _conn_fields(payload: dict) -> tuple[str, str | None, int | None]:
|
|
361
|
+
"""四栏入参 → (host, user, port);兼容旧前端的 target 字符串。
|
|
362
|
+
|
|
363
|
+
端口只认纯数字(坏值退回 None=默认 22),与 server._conn_fields 同
|
|
364
|
+
语义,避免 "host:ssh" 这类被误切成端口。
|
|
365
|
+
"""
|
|
366
|
+
from ..connections.models import parse_user_at_host
|
|
367
|
+
|
|
368
|
+
host = (payload.get("host") or "").strip()
|
|
369
|
+
user = (payload.get("user") or "").strip() or None
|
|
370
|
+
raw = str(payload.get("port") or "").strip()
|
|
371
|
+
port = int(raw) if raw.isdigit() else None
|
|
372
|
+
if host:
|
|
373
|
+
return host, user, port
|
|
374
|
+
h, u, p = parse_user_at_host((payload.get("target") or "").strip())
|
|
375
|
+
return h, u or user, (p if p is not None else port)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _register_disposal(worker) -> None:
|
|
379
|
+
"""把释放钩子挂到 ``worker._disposables``(幂等)。
|
|
380
|
+
|
|
381
|
+
``close()`` 统一触发钩子——旧实现靠调用点记得调 ``close_cascade``,
|
|
382
|
+
断线回收与 app 关闭两条路径都漏了,SSH 连接一直挂着不放。
|
|
383
|
+
"""
|
|
384
|
+
hooks = getattr(worker, "_disposables", None)
|
|
385
|
+
if hooks is None:
|
|
386
|
+
hooks = []
|
|
387
|
+
worker._disposables = hooks
|
|
388
|
+
if any(getattr(h, "_ot_panel", False) for h in hooks):
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
async def _dispose() -> None:
|
|
392
|
+
await close_panel(worker)
|
|
393
|
+
|
|
394
|
+
_dispose._ot_panel = True
|
|
395
|
+
hooks.append(_dispose)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def _realpath(sftp, path: str) -> str:
|
|
399
|
+
"""绝对化路径(面包屑用)。个别服务器 realpath 失败就回退原样。"""
|
|
400
|
+
with suppress(Exception):
|
|
401
|
+
p = await sftp.realpath(path)
|
|
402
|
+
return str(p)
|
|
403
|
+
return path
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# --- 路由 -----------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
def install_files_errors(app) -> None:
|
|
409
|
+
"""把 PanelError 渲染成带 code/retry 的 JSON。
|
|
410
|
+
|
|
411
|
+
FastAPI 对 HTTPException 只吐 ``{"detail": ...}``,前端分派不出
|
|
412
|
+
「该不该自动重试」。Starlette 按异常类的 MRO 找 handler,故注册到
|
|
413
|
+
PanelError 上即可盖过默认的 HTTPException 处理器。
|
|
414
|
+
"""
|
|
415
|
+
from fastapi.responses import JSONResponse as _JR
|
|
416
|
+
|
|
417
|
+
@app.exception_handler(PanelError)
|
|
418
|
+
async def _panel_error(_request, exc: PanelError):
|
|
419
|
+
return _JR(status_code=exc.status_code, content=exc.payload())
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def make_files_router(tabs: dict, check_token) -> APIRouter:
|
|
423
|
+
router = APIRouter(prefix="/api/tabs/{tab_id}/files")
|
|
424
|
+
|
|
425
|
+
def _worker(tab_id: str):
|
|
426
|
+
worker = tabs.get(tab_id)
|
|
427
|
+
if worker is None:
|
|
428
|
+
raise PanelError(404, "no_tab", "会话不存在")
|
|
429
|
+
return worker
|
|
430
|
+
|
|
431
|
+
def _conn(worker):
|
|
432
|
+
"""面板自己的连接:没有就说明还没登录,前端该弹框。"""
|
|
433
|
+
c = getattr(worker, "_panel_files", None)
|
|
434
|
+
if c is None or not c.host:
|
|
435
|
+
raise PanelError(409, "not_connected",
|
|
436
|
+
"未连接远程主机,请先登录", need_password=True)
|
|
437
|
+
return c
|
|
438
|
+
|
|
439
|
+
async def _channel(tab_id: str) -> _Channel:
|
|
440
|
+
"""端点公共入口:拿到面板那条直连的 SFTP。
|
|
441
|
+
|
|
442
|
+
错误按 code 分三类,前端据此分派(**永远不回 401**——那是访问
|
|
443
|
+
令牌的专属语义,撞上会莫名弹令牌框):
|
|
444
|
+
|
|
445
|
+
* ``auth_failed``(403)——密码不对,**不自动重试**,弹框换密码;
|
|
446
|
+
* ``not_ready``(503, retry)——网络抖动,自动退避重连;
|
|
447
|
+
* ``host_key_unknown``(428)/``host_key_changed``(400)——TOFU。
|
|
448
|
+
"""
|
|
449
|
+
worker = _worker(tab_id)
|
|
450
|
+
c = _conn(worker)
|
|
451
|
+
try:
|
|
452
|
+
sftp = await c.sftp()
|
|
453
|
+
except AuthFailed as exc:
|
|
454
|
+
raise PanelError(403, "auth_failed", str(exc),
|
|
455
|
+
need_password=True) from None
|
|
456
|
+
except HostKeyUnknown as exc:
|
|
457
|
+
raise PanelError(428, "host_key_unknown", exc.detail,
|
|
458
|
+
algorithm=exc.algorithm,
|
|
459
|
+
fingerprint=exc.fingerprint,
|
|
460
|
+
host=exc.host) from None
|
|
461
|
+
except HostKeyChanged as exc:
|
|
462
|
+
raise PanelError(400, "host_key_changed", str(exc)) from None
|
|
463
|
+
except OSError as exc:
|
|
464
|
+
raise PanelError(503, "not_ready", f"连接不可用: {exc}",
|
|
465
|
+
retry=True) from None
|
|
466
|
+
except Exception as exc: # noqa: BLE001 - 其余都算可重试
|
|
467
|
+
raise PanelError(503, "not_ready",
|
|
468
|
+
f"打开 SFTP 失败: {type(exc).__name__}: {exc}",
|
|
469
|
+
retry=True) from None
|
|
470
|
+
return _Channel(sftp, worker)
|
|
471
|
+
|
|
472
|
+
def _code(exc) -> tuple[int, str]:
|
|
473
|
+
import asyncssh
|
|
474
|
+
|
|
475
|
+
if isinstance(exc, asyncssh.SFTPNoSuchFile):
|
|
476
|
+
return 404, "not_found"
|
|
477
|
+
return 400, "io"
|
|
478
|
+
|
|
479
|
+
async def _io(what: str, coro):
|
|
480
|
+
"""SFTP 调用的统一异常映射(业务代码里不再堆 try/except)。"""
|
|
481
|
+
import asyncssh
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
return await coro
|
|
485
|
+
except PanelError:
|
|
486
|
+
raise
|
|
487
|
+
except asyncssh.SFTPError as exc:
|
|
488
|
+
status, code = _code(exc)
|
|
489
|
+
raise PanelError(status, code, f"{what}: {exc}") from None
|
|
490
|
+
except Exception as exc: # noqa: BLE001
|
|
491
|
+
raise PanelError(500, "io", f"{what}: {type(exc).__name__}: {exc}") from None
|
|
492
|
+
|
|
493
|
+
# --- 状态:连着没有 + 登录弹框的默认值 -------------------------------
|
|
494
|
+
|
|
495
|
+
def _defaults(ident: dict, worker) -> dict:
|
|
496
|
+
"""登录弹框的默认值:IP/端口=终端最终服务器,用户=终端当前用户。
|
|
497
|
+
|
|
498
|
+
需求 1 的「默认值」全在这里产出。面板连哪台与它无关——它只回答
|
|
499
|
+
「用户打开面板时表单该预填什么」。
|
|
500
|
+
"""
|
|
501
|
+
host = ident.get("host") or ""
|
|
502
|
+
# 本机终端没有「最终服务器」:pipeline 会给字面量 "local",
|
|
503
|
+
# 别把它当 IP 填进表单
|
|
504
|
+
supported = (bool(host) and host != "local"
|
|
505
|
+
and ident.get("mode") != "local")
|
|
506
|
+
port = ident.get("port")
|
|
507
|
+
if not ident.get("nested") and port is None:
|
|
508
|
+
port = getattr(getattr(worker, "_target", None), "port", None)
|
|
509
|
+
user = (ident.get("effective_user")
|
|
510
|
+
or (ident.get("users") or [""])[0]) or ""
|
|
511
|
+
return {
|
|
512
|
+
"host": host if supported else "",
|
|
513
|
+
"port": (port if supported else None),
|
|
514
|
+
"user": user if supported else "",
|
|
515
|
+
"users": ident.get("users") or [],
|
|
516
|
+
"nested": bool(ident.get("nested")),
|
|
517
|
+
"supported": supported,
|
|
518
|
+
"has_password": (_has_password(host, user, port)
|
|
519
|
+
if supported else False),
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
@router.get("/state")
|
|
523
|
+
async def state(request: Request, tab_id: str):
|
|
524
|
+
"""面板状态:连着没有、连着哪台、登录弹框该填什么。
|
|
525
|
+
|
|
526
|
+
**不依赖终端会话**——会话还没连上或已经断了也照常返回,面板自管
|
|
527
|
+
连接(「文件面板和终端完全独立」的落地点)。``defaults`` 只供
|
|
528
|
+
登录弹框预填。
|
|
529
|
+
|
|
530
|
+
``home`` 恒为 ".":SFTP 的默认目录就是登录用户的家目录(需求 3)。
|
|
531
|
+
面板与终端是两套独立机制,不跟终端 cwd。
|
|
532
|
+
"""
|
|
533
|
+
check_token(request.headers)
|
|
534
|
+
worker = _worker(tab_id)
|
|
535
|
+
defaults = _defaults(files_identity(worker), worker)
|
|
536
|
+
c = getattr(worker, "_panel_files", None)
|
|
537
|
+
if c is not None and c.alive:
|
|
538
|
+
# supported 是「该不该有 📁 入口」的产品判据,与连没连无关
|
|
539
|
+
return {"connected": True, "supported": defaults["supported"],
|
|
540
|
+
"reason": None,
|
|
541
|
+
"home": ".", "host": c.host, "port": c.port,
|
|
542
|
+
"user": c.user or "", "defaults": defaults}
|
|
543
|
+
return {"connected": False, "supported": defaults["supported"],
|
|
544
|
+
"reason": "not_connected", "home": ".",
|
|
545
|
+
"host": "", "port": None, "user": "", "defaults": defaults}
|
|
546
|
+
|
|
547
|
+
# --- 连接管理 --------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
@router.post("/connect")
|
|
550
|
+
async def panel_connect(request: Request, tab_id: str, payload: dict):
|
|
551
|
+
"""登录远程主机(四栏:IP/端口/用户名/密码),直连不经隧道。
|
|
552
|
+
|
|
553
|
+
``password`` 留空表示「用记住的」——服务端按该身份查凭据库。
|
|
554
|
+
读走 ``load_password_alias``(port=None/22 互查)、写归一到
|
|
555
|
+
``_cred_port``,两端键才对得上。
|
|
556
|
+
|
|
557
|
+
**永远不回 401**:那是访问令牌的专属语义,前端见 401 就弹令牌框
|
|
558
|
+
(真机反馈:用户被莫名要求填访问令牌)。认证失败回 403。
|
|
559
|
+
"""
|
|
560
|
+
check_token(request.headers)
|
|
561
|
+
worker = _worker(tab_id)
|
|
562
|
+
host, user, port = _conn_fields(payload)
|
|
563
|
+
if not host:
|
|
564
|
+
raise PanelError(400, "bad_target", "请填写 IP 地址")
|
|
565
|
+
password = payload.get("password") or None
|
|
566
|
+
if not password:
|
|
567
|
+
from ..connections.secrets import load_password_alias
|
|
568
|
+
|
|
569
|
+
password = load_password_alias(host, user, _cred_port(port))
|
|
570
|
+
import asyncssh
|
|
571
|
+
|
|
572
|
+
c = getattr(worker, "_panel_files", None)
|
|
573
|
+
if c is None:
|
|
574
|
+
c = _PanelConn() # 检查与赋值间无 await,事件循环内原子
|
|
575
|
+
worker._panel_files = c
|
|
576
|
+
async with c.lock:
|
|
577
|
+
c.teardown() # 重连语义:先弃旧连接
|
|
578
|
+
c.need_password = False # 新一轮凭据,解闩
|
|
579
|
+
try:
|
|
580
|
+
conn, sftp = await connect_panel(
|
|
581
|
+
host=host, port=port, user=user, password=password,
|
|
582
|
+
confirm_host_key=bool(payload.get("confirm_host_key")))
|
|
583
|
+
except HostKeyUnknown as exc:
|
|
584
|
+
return JSONResponse(
|
|
585
|
+
status_code=428,
|
|
586
|
+
content={"detail": exc.detail, "algorithm": exc.algorithm,
|
|
587
|
+
"fingerprint": exc.fingerprint, "host": exc.host})
|
|
588
|
+
except HostKeyChanged as exc:
|
|
589
|
+
raise PanelError(400, "host_key_changed", str(exc)) from None
|
|
590
|
+
except AuthFailed as exc:
|
|
591
|
+
c.forget_password()
|
|
592
|
+
raise PanelError(403, "auth_failed", str(exc),
|
|
593
|
+
need_password=True) from None
|
|
594
|
+
except (OSError, asyncssh.Error) as exc:
|
|
595
|
+
raise PanelError(502, "connect_failed",
|
|
596
|
+
f"连接失败: {exc}") from None
|
|
597
|
+
c.host, c.port, c.user, c.password = host, port, user, password
|
|
598
|
+
c.conn, c.sftp_client = conn, sftp
|
|
599
|
+
if payload.get("remember") and password:
|
|
600
|
+
from ..connections.secrets import store_password
|
|
601
|
+
|
|
602
|
+
store_password(host, user, _cred_port(port), password)
|
|
603
|
+
_register_disposal(worker)
|
|
604
|
+
return {"ok": True, "host": host, "port": port, "user": user or ""}
|
|
605
|
+
|
|
606
|
+
@router.post("/disconnect")
|
|
607
|
+
async def panel_disconnect(request: Request, tab_id: str):
|
|
608
|
+
check_token(request.headers)
|
|
609
|
+
await close_panel(_worker(tab_id))
|
|
610
|
+
return {"ok": True}
|
|
611
|
+
|
|
612
|
+
# --- 面板端点(统一走面板自己的直连) --------------------------------
|
|
613
|
+
|
|
614
|
+
@router.get("/list")
|
|
615
|
+
async def list_dir(request: Request, tab_id: str, path: str = "."):
|
|
616
|
+
check_token(request.headers)
|
|
617
|
+
ch = await _channel(tab_id)
|
|
618
|
+
target = _norm(path)
|
|
619
|
+
entries = await _io("列目录失败", ch.sftp.readdir(target))
|
|
620
|
+
out = []
|
|
621
|
+
for entry in entries:
|
|
622
|
+
if entry.filename in (".", ".."):
|
|
623
|
+
continue
|
|
624
|
+
attrs = entry.attrs
|
|
625
|
+
out.append({
|
|
626
|
+
"name": entry.filename,
|
|
627
|
+
"is_dir": _is_dir(attrs),
|
|
628
|
+
"size": attrs.size or 0,
|
|
629
|
+
"mtime": attrs.mtime or 0,
|
|
630
|
+
})
|
|
631
|
+
out.sort(key=lambda e: (not e["is_dir"], e["name"]))
|
|
632
|
+
return {"path": await _realpath(ch.sftp, target), "entries": out}
|
|
633
|
+
|
|
634
|
+
@router.post("/upload")
|
|
635
|
+
async def upload(request: Request, tab_id: str, path: str = ".",
|
|
636
|
+
name: str = ""):
|
|
637
|
+
check_token(request.headers)
|
|
638
|
+
ch = await _channel(tab_id)
|
|
639
|
+
remote = _join(path, name)
|
|
640
|
+
tmp = _temp_name(remote)
|
|
641
|
+
written = 0
|
|
642
|
+
try:
|
|
643
|
+
# 先写临时文件再改名:中途失败只清临时文件,用户原文件原样保留
|
|
644
|
+
async with ch.sftp.open(tmp, "wb") as f:
|
|
645
|
+
async for chunk in request.stream():
|
|
646
|
+
if chunk:
|
|
647
|
+
await f.write(chunk)
|
|
648
|
+
written += len(chunk)
|
|
649
|
+
await _replace(ch.sftp, tmp, remote)
|
|
650
|
+
except PanelError:
|
|
651
|
+
with suppress(Exception):
|
|
652
|
+
await ch.sftp.remove(tmp)
|
|
653
|
+
raise
|
|
654
|
+
except Exception as exc: # noqa: BLE001
|
|
655
|
+
with suppress(Exception):
|
|
656
|
+
await ch.sftp.remove(tmp)
|
|
657
|
+
import asyncssh
|
|
658
|
+
|
|
659
|
+
if isinstance(exc, asyncssh.SFTPError):
|
|
660
|
+
status, code = _code(exc)
|
|
661
|
+
raise PanelError(status, code, f"上传失败: {exc}") from None
|
|
662
|
+
raise PanelError(500, "io",
|
|
663
|
+
f"上传失败: {type(exc).__name__}: {exc}") from None
|
|
664
|
+
return {"ok": True, "path": remote, "size": written}
|
|
665
|
+
|
|
666
|
+
@router.get("/download")
|
|
667
|
+
async def download(request: Request, tab_id: str, path: str):
|
|
668
|
+
check_token(request.headers)
|
|
669
|
+
ch = await _channel(tab_id)
|
|
670
|
+
target = _norm(path)
|
|
671
|
+
attrs = await _io("下载失败", ch.sftp.stat(target))
|
|
672
|
+
name = posixpath.basename(target.rstrip("/")) or "download"
|
|
673
|
+
headers = {
|
|
674
|
+
"Content-Disposition":
|
|
675
|
+
f"attachment; filename*=UTF-8''{urlquote(name)}"}
|
|
676
|
+
if getattr(attrs, "size", None) is not None:
|
|
677
|
+
headers["Content-Length"] = str(attrs.size)
|
|
678
|
+
|
|
679
|
+
async def gen():
|
|
680
|
+
async with ch.sftp.open(target, "rb") as f:
|
|
681
|
+
while chunk := await f.read(_CHUNK):
|
|
682
|
+
yield chunk
|
|
683
|
+
|
|
684
|
+
return StreamingResponse(gen(), media_type="application/octet-stream",
|
|
685
|
+
headers=headers)
|
|
686
|
+
|
|
687
|
+
@router.post("/mkdir")
|
|
688
|
+
async def mkdir(request: Request, tab_id: str, path: str):
|
|
689
|
+
check_token(request.headers)
|
|
690
|
+
ch = await _channel(tab_id)
|
|
691
|
+
await _io("创建文件夹失败", ch.sftp.mkdir(_norm(path)))
|
|
692
|
+
return {"ok": True, "path": _norm(path)}
|
|
693
|
+
|
|
694
|
+
@router.post("/create")
|
|
695
|
+
async def create_file(request: Request, tab_id: str, path: str):
|
|
696
|
+
check_token(request.headers)
|
|
697
|
+
ch = await _channel(tab_id)
|
|
698
|
+
target = _norm(path)
|
|
699
|
+
import asyncssh
|
|
700
|
+
|
|
701
|
+
# 先探再建:存在的判据不猜异常文案(不同服务器措辞不一),只信 stat。
|
|
702
|
+
try:
|
|
703
|
+
await ch.sftp.stat(target)
|
|
704
|
+
except Exception: # noqa: BLE001 - 不存在/不可判都往下走
|
|
705
|
+
pass
|
|
706
|
+
else:
|
|
707
|
+
raise PanelError(409, "exists", f"已存在同名文件:{target}",
|
|
708
|
+
exists=True)
|
|
709
|
+
try:
|
|
710
|
+
# "x" = FXF_WRITE|FXF_CREAT|FXF_EXCL:已存在即失败,绝不截断。
|
|
711
|
+
# 旧实现用 "wb"(FXF_TRUNC),换个老实的 SFTP 服务器就把同名旧
|
|
712
|
+
# 文件清成 0 字节——「新建文件」变成数据损坏。
|
|
713
|
+
async with ch.sftp.open(target, "x") as f:
|
|
714
|
+
await f.write(b"")
|
|
715
|
+
except asyncssh.SFTPError as exc:
|
|
716
|
+
# 竞态兜底:stat 与 open 之间被别人建出来了
|
|
717
|
+
if isinstance(exc, asyncssh.SFTPFailure):
|
|
718
|
+
try:
|
|
719
|
+
await ch.sftp.stat(target)
|
|
720
|
+
except Exception: # noqa: BLE001
|
|
721
|
+
pass
|
|
722
|
+
else:
|
|
723
|
+
raise PanelError(409, "exists",
|
|
724
|
+
f"已存在同名文件:{target}",
|
|
725
|
+
exists=True) from None
|
|
726
|
+
status, code = _code(exc)
|
|
727
|
+
raise PanelError(status, code, f"创建文件失败: {exc}") from None
|
|
728
|
+
except Exception as exc: # noqa: BLE001
|
|
729
|
+
raise PanelError(500, "io",
|
|
730
|
+
f"创建文件失败: {type(exc).__name__}: {exc}") from None
|
|
731
|
+
return {"ok": True, "path": target}
|
|
732
|
+
|
|
733
|
+
@router.get("/read")
|
|
734
|
+
async def read_file(request: Request, tab_id: str, path: str):
|
|
735
|
+
check_token(request.headers)
|
|
736
|
+
ch = await _channel(tab_id)
|
|
737
|
+
target = _norm(path)
|
|
738
|
+
attrs = await _io("读取文件失败", ch.sftp.stat(target))
|
|
739
|
+
if getattr(attrs, "size", 0) and attrs.size > _EDIT_LIMIT:
|
|
740
|
+
raise PanelError(400, "too_large",
|
|
741
|
+
f"文件过大({_EDIT_LIMIT // 1024 // 1024}MB 上限),"
|
|
742
|
+
"不支持在线编辑,请下载后编辑")
|
|
743
|
+
data = await _io("读取文件失败", _read_all(ch.sftp, target))
|
|
744
|
+
return {"path": target, "name": posixpath.basename(target.rstrip("/")),
|
|
745
|
+
"size": len(data),
|
|
746
|
+
"content": bytes(data).decode("utf-8", errors="replace")}
|
|
747
|
+
|
|
748
|
+
async def _read_all(sftp, path: str) -> bytes:
|
|
749
|
+
async with sftp.open(path, "rb") as f:
|
|
750
|
+
return await f.read(_EDIT_LIMIT + 1)
|
|
751
|
+
|
|
752
|
+
@router.post("/write")
|
|
753
|
+
async def write_file(request: Request, tab_id: str, path: str):
|
|
754
|
+
check_token(request.headers)
|
|
755
|
+
body = await request.body()
|
|
756
|
+
if len(body) > _EDIT_LIMIT:
|
|
757
|
+
raise PanelError(400, "too_large",
|
|
758
|
+
f"内容过大({_EDIT_LIMIT // 1024 // 1024}MB 上限),"
|
|
759
|
+
"不支持在线保存")
|
|
760
|
+
ch = await _channel(tab_id)
|
|
761
|
+
target = _norm(path)
|
|
762
|
+
await _io("保存文件失败", _write_all(ch.sftp, target, body))
|
|
763
|
+
return {"ok": True, "path": target, "size": len(body)}
|
|
764
|
+
|
|
765
|
+
async def _write_all(sftp, path: str, body: bytes) -> None:
|
|
766
|
+
tmp = _temp_name(path)
|
|
767
|
+
try:
|
|
768
|
+
async with sftp.open(tmp, "wb") as f:
|
|
769
|
+
await f.write(body)
|
|
770
|
+
await _replace(sftp, tmp, path)
|
|
771
|
+
except Exception: # noqa: BLE001
|
|
772
|
+
with suppress(Exception):
|
|
773
|
+
await sftp.remove(tmp)
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
@router.post("/delete")
|
|
777
|
+
async def delete_path(request: Request, tab_id: str, path: str):
|
|
778
|
+
check_token(request.headers)
|
|
779
|
+
ch = await _channel(tab_id)
|
|
780
|
+
target = _norm(path)
|
|
781
|
+
attrs = await _io("删除失败", ch.sftp.stat(target))
|
|
782
|
+
is_dir = _is_dir(attrs)
|
|
783
|
+
if is_dir:
|
|
784
|
+
await _io("删除失败", ch.sftp.rmtree(target)) # 前端确认框已明示递归
|
|
785
|
+
else:
|
|
786
|
+
await _io("删除失败", ch.sftp.remove(target))
|
|
787
|
+
return {"ok": True, "path": target, "is_dir": is_dir}
|
|
788
|
+
|
|
789
|
+
return router
|