py-web-ssh 0.1.4__tar.gz → 0.1.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-web-ssh
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
  - 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
34
34
  - 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
35
35
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
36
- - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
36
+ - 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
37
37
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
38
38
  - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
39
39
  - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
@@ -11,7 +11,7 @@
11
11
  - 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
12
12
  - 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
13
13
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
14
- - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
14
+ - 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
15
15
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
16
16
  - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
17
17
  - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-web-ssh"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.1.5"
@@ -14,9 +14,15 @@ from . import __version__
14
14
  from . import auth
15
15
  from .auth import add_pin_argument, configure_pin
16
16
  from .client_session import ensure_client_session_cookie
17
- from .files import download_file, filename_for_download, upload_file
17
+ from .files import (
18
+ FileTransferCancelled,
19
+ download_file_via_ssh,
20
+ filename_for_download,
21
+ upload_file_via_ssh,
22
+ )
18
23
  from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
19
24
  from .session import SessionManager
25
+ from .transfers import TransferManager
20
26
 
21
27
 
22
28
  BASE_DIR = Path(__file__).resolve().parent
@@ -28,6 +34,7 @@ DEFAULT_PORT = 8022
28
34
  app = FastAPI(title="py-web-ssh", version=__version__)
29
35
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
30
36
  sessions = SessionManager()
37
+ transfers = TransferManager()
31
38
 
32
39
 
33
40
  @app.middleware("http")
@@ -118,19 +125,35 @@ def upload(
118
125
  session_id: str,
119
126
  remote_path: Annotated[str, Form(min_length=1)],
120
127
  file: Annotated[UploadFile, File()],
128
+ transfer_id: Annotated[str | None, Form()] = None,
129
+ total_bytes: Annotated[int | None, Form()] = None,
121
130
  ) -> FileTransferResponse:
122
131
  session = _require_session(session_id)
132
+ tracker = transfers.get(transfer_id) if transfer_id else None
133
+ if transfer_id and tracker is None:
134
+ raise HTTPException(status_code=404, detail="Transfer not found.")
135
+ if tracker is None:
136
+ tracker = transfers.create_upload(total_bytes or _content_length(file), remote_path)
123
137
  try:
124
- method, transferred = upload_file(
125
- session.ssh_client,
138
+ method, transferred = upload_file_via_ssh(
139
+ session.config,
126
140
  file.file,
127
141
  remote_path,
128
- _content_length(file),
142
+ total_bytes or _content_length(file),
143
+ cancel_event=tracker.cancel_event,
144
+ progress=tracker.update_progress,
129
145
  )
146
+ except FileTransferCancelled as exc:
147
+ message = f"File upload cancelled: {exc}"
148
+ tracker.cancelled(message)
149
+ session.log("warning", message, None)
150
+ raise HTTPException(status_code=499, detail=message) from exc
130
151
  except Exception as exc:
152
+ tracker.fail(str(exc))
131
153
  session.log("error", f"File upload failed: {exc}", None)
132
154
  raise HTTPException(status_code=500, detail=str(exc)) from exc
133
155
  message = f"Uploaded {transferred} bytes to {remote_path} using {method}."
156
+ tracker.complete(transferred, message)
134
157
  session.log("info", message, None)
135
158
  return FileTransferResponse(
136
159
  ok=True,
@@ -138,14 +161,63 @@ def upload(
138
161
  bytes_transferred=transferred,
139
162
  remote_path=remote_path,
140
163
  message=message,
164
+ transfer_id=tracker.id,
141
165
  )
142
166
 
143
167
 
168
+ @app.post("/api/sessions/{session_id}/files/uploads")
169
+ async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
170
+ _require_session(session_id)
171
+ payload = await request.json()
172
+ remote_path = str(payload.get("remote_path", "")).strip()
173
+ if not remote_path:
174
+ raise HTTPException(status_code=422, detail="remote_path is required.")
175
+ total_bytes = payload.get("total_bytes")
176
+ if total_bytes is not None:
177
+ total_bytes = int(total_bytes)
178
+ tracker = transfers.create_upload(total_bytes, remote_path)
179
+ return JSONResponse(
180
+ {
181
+ "transfer_id": tracker.id,
182
+ "state": tracker.status().state,
183
+ "remote_path": remote_path,
184
+ "total_bytes": total_bytes,
185
+ }
186
+ )
187
+
188
+
189
+ @app.get("/api/transfers/{transfer_id}")
190
+ def get_transfer(transfer_id: str) -> JSONResponse:
191
+ tracker = transfers.get(transfer_id)
192
+ if tracker is None:
193
+ raise HTTPException(status_code=404, detail="Transfer not found.")
194
+ status = tracker.status()
195
+ return JSONResponse(
196
+ {
197
+ "transfer_id": status.transfer_id,
198
+ "state": status.state,
199
+ "bytes_transferred": status.bytes_transferred,
200
+ "total_bytes": status.total_bytes,
201
+ "remote_path": status.remote_path,
202
+ "message": status.message,
203
+ "created_at": status.created_at.isoformat(),
204
+ "updated_at": status.updated_at.isoformat(),
205
+ }
206
+ )
207
+
208
+
209
+ @app.delete("/api/transfers/{transfer_id}")
210
+ def cancel_transfer(transfer_id: str) -> JSONResponse:
211
+ if not transfers.cancel(transfer_id):
212
+ raise HTTPException(status_code=404, detail="Transfer not found.")
213
+ return JSONResponse({"ok": True})
214
+
215
+
144
216
  @app.get("/api/sessions/{session_id}/files/download")
145
217
  def download(session_id: str, remote_path: str) -> StreamingResponse:
146
218
  session = _require_session(session_id)
147
219
  try:
148
- method, stream = download_file(session.ssh_client, remote_path)
220
+ method, stream = download_file_via_ssh(session.config, remote_path)
149
221
  except Exception as exc:
150
222
  session.log("error", f"File download failed: {exc}", None)
151
223
  raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import os
6
+ import posixpath
7
+ import shlex
8
+ import threading
9
+ import uuid
10
+ from collections.abc import Callable, Iterator
11
+ from typing import BinaryIO
12
+
13
+ from .models import ConnectRequest
14
+ from .ssh_client import connect_ssh
15
+
16
+
17
+ class FileTransferError(RuntimeError):
18
+ pass
19
+
20
+
21
+ class FileTransferCancelled(FileTransferError):
22
+ pass
23
+
24
+
25
+ ProgressCallback = Callable[[int], None]
26
+
27
+
28
+ def upload_file_via_ssh(
29
+ config: ConnectRequest,
30
+ source: BinaryIO,
31
+ remote_path: str,
32
+ size: int | None,
33
+ cancel_event: threading.Event | None = None,
34
+ progress: ProgressCallback | None = None,
35
+ ) -> tuple[str, int]:
36
+ """Upload using only an SSH exec channel and POSIX-ish shell commands.
37
+
38
+ This intentionally does not use SFTP or SCP. It follows the simple-ssh-copy
39
+ style: stream base64 into a remote shell, decode into a temporary file, and
40
+ atomically move the temp file into place after the complete upload succeeds.
41
+ """
42
+
43
+ connected = connect_ssh(config, lambda _level, _message, _details=None: None)
44
+ client = connected.client
45
+ remote_dir = posixpath.dirname(remote_path) or "."
46
+ temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
47
+ command = _upload_command(remote_dir, temp_path, remote_path)
48
+ stdin = stdout = stderr = None
49
+ transferred = 0
50
+ try:
51
+ stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
52
+ while True:
53
+ if cancel_event and cancel_event.is_set():
54
+ raise FileTransferCancelled("Upload cancelled by client.")
55
+ chunk = source.read(48 * 1024)
56
+ if not chunk:
57
+ break
58
+ transferred += len(chunk)
59
+ stdin.write(base64.b64encode(chunk).decode("ascii"))
60
+ stdin.write("\n")
61
+ stdin.flush()
62
+ if progress:
63
+ progress(transferred)
64
+
65
+ if cancel_event and cancel_event.is_set():
66
+ raise FileTransferCancelled("Upload cancelled by client.")
67
+
68
+ stdin.channel.shutdown_write()
69
+ exit_code = stdout.channel.recv_exit_status()
70
+ error_text = stderr.read().decode("utf-8", errors="replace")
71
+ except FileTransferCancelled:
72
+ _close_transport(client)
73
+ raise
74
+ except Exception as exc:
75
+ _close_transport(client)
76
+ raise FileTransferError(f"SSH shell upload failed: {exc}") from exc
77
+ finally:
78
+ for stream in (stdin, stdout, stderr):
79
+ if stream is not None:
80
+ try:
81
+ stream.close()
82
+ except Exception:
83
+ pass
84
+ client.close()
85
+
86
+ if exit_code != 0:
87
+ raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
88
+ if size is not None and transferred != size:
89
+ raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
90
+ return "shell", transferred
91
+
92
+
93
+ def download_file_via_ssh(config: ConnectRequest, remote_path: str) -> tuple[str, Iterator[bytes]]:
94
+ connected = connect_ssh(config, lambda _level, _message, _details=None: None)
95
+ client = connected.client
96
+ command = f"base64 < {shlex.quote(remote_path)}"
97
+ stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
98
+ stdin.close()
99
+
100
+ def iterator() -> Iterator[bytes]:
101
+ buffered = b""
102
+ try:
103
+ while True:
104
+ chunk = stdout.channel.recv(64 * 1024)
105
+ if not chunk:
106
+ break
107
+ buffered += b"".join(chunk.split())
108
+ keep = len(buffered) % 4
109
+ ready = buffered[:-keep] if keep else buffered
110
+ buffered = buffered[-keep:] if keep else b""
111
+ if ready:
112
+ yield base64.b64decode(ready)
113
+ if buffered:
114
+ yield base64.b64decode(buffered)
115
+ exit_code = stdout.channel.recv_exit_status()
116
+ error_text = stderr.read().decode("utf-8", errors="replace")
117
+ if exit_code != 0:
118
+ raise FileTransferError(
119
+ f"Remote download command failed with exit code {exit_code}: {error_text}"
120
+ )
121
+ except binascii.Error as exc:
122
+ raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
123
+ finally:
124
+ stdout.close()
125
+ stderr.close()
126
+ client.close()
127
+
128
+ return "shell", iterator()
129
+
130
+
131
+ def _upload_command(remote_dir: str, temp_path: str, remote_path: str) -> str:
132
+ quoted_temp = shlex.quote(temp_path)
133
+ return "\n".join(
134
+ [
135
+ "set -e",
136
+ f"mkdir -p -- {shlex.quote(remote_dir)}",
137
+ f"tmp={quoted_temp}",
138
+ "cleanup() { rm -f -- \"$tmp\"; }",
139
+ "trap cleanup EXIT HUP INT TERM",
140
+ f"if printf '' | base64 -d >/dev/null 2>&1; then base64 -d > {quoted_temp}; "
141
+ f"else base64 -D > {quoted_temp}; fi",
142
+ f"mv -- {quoted_temp} {shlex.quote(remote_path)}",
143
+ "trap - EXIT",
144
+ ]
145
+ )
146
+
147
+
148
+ def _close_transport(client) -> None:
149
+ try:
150
+ transport = client.get_transport()
151
+ if transport is not None:
152
+ transport.close()
153
+ except Exception:
154
+ pass
155
+ try:
156
+ client.close()
157
+ except Exception:
158
+ pass
159
+
160
+
161
+ def filename_for_download(remote_path: str) -> str:
162
+ name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
163
+ return os.path.basename(name)
@@ -70,7 +70,8 @@ class SessionSummary(BaseModel):
70
70
 
71
71
  class FileTransferResponse(BaseModel):
72
72
  ok: bool
73
- method: Literal["sftp", "shell"]
73
+ method: Literal["shell"]
74
74
  bytes_transferred: int
75
75
  remote_path: str
76
76
  message: str
77
+ transfer_id: str | None = None
@@ -9,6 +9,10 @@ const pinForm = document.querySelector("#pin-form");
9
9
  const pinInput = document.querySelector("#pin-input");
10
10
  const pinError = document.querySelector("#pin-error");
11
11
  const panelToggles = document.querySelectorAll(".panel-toggle");
12
+ const uploadProgress = document.querySelector("#upload-progress");
13
+ const uploadProgressText = document.querySelector("#upload-progress-text");
14
+ const uploadProgressBar = document.querySelector("#upload-progress-bar");
15
+ const cancelUploadButton = document.querySelector("#cancel-upload");
12
16
 
13
17
  const term = new Terminal({
14
18
  cursorBlink: true,
@@ -50,6 +54,7 @@ let ws = null;
50
54
  let activeSessionId = localStorage.getItem("py-web-ssh-session") || "";
51
55
  let lastAppliedSeq = 0;
52
56
  let snapshotTimer = null;
57
+ let activeUpload = null;
53
58
 
54
59
  if (activeSessionId) {
55
60
  sessionInput.value = activeSessionId;
@@ -153,15 +158,81 @@ document.querySelector("#upload-form").addEventListener("submit", async (event)
153
158
  }
154
159
  const form = new FormData();
155
160
  form.append("remote_path", remotePath);
161
+ form.append("total_bytes", String(file.size));
156
162
  form.append("file", file);
163
+ let xhr = null;
164
+ const uploadState = {
165
+ xhr: null,
166
+ transferId: null,
167
+ pollTimer: null,
168
+ cancelled: false,
169
+ fileSize: file.size,
170
+ };
171
+ activeUpload = uploadState;
172
+ showUploadProgress(0, file.size, "准备上传...");
157
173
  setStatus("上传中...");
158
- const response = await fetch(`/api/sessions/${activeSessionId}/files/upload`, {
174
+ try {
175
+ const task = await createUploadTask(activeSessionId, remotePath, file.size);
176
+ uploadState.transferId = task.transfer_id;
177
+ form.append("transfer_id", uploadState.transferId);
178
+ startUploadPolling(uploadState);
179
+ xhr = uploadWithXhr(`/api/sessions/${activeSessionId}/files/upload`, form, uploadState);
180
+ uploadState.xhr = xhr;
181
+ const result = await xhr;
182
+ showUploadProgress(result.bytes_transferred, file.size, "上传完成");
183
+ appendLogLine(`上传完成: ${JSON.stringify(result)}`);
184
+ setStatus("上传完成");
185
+ } catch (error) {
186
+ appendLogLine(uploadState.cancelled ? "上传已取消。" : `上传失败: ${error}`);
187
+ setStatus(uploadState.cancelled ? "上传已取消" : "上传失败");
188
+ } finally {
189
+ stopUploadPolling(uploadState);
190
+ if (activeUpload === uploadState) activeUpload = null;
191
+ }
192
+ });
193
+
194
+ async function createUploadTask(sessionId, remotePath, totalBytes) {
195
+ const response = await fetch(`/api/sessions/${sessionId}/files/uploads`, {
159
196
  method: "POST",
160
- body: form,
197
+ headers: { "Content-Type": "application/json" },
198
+ body: JSON.stringify({ remote_path: remotePath, total_bytes: totalBytes }),
199
+ });
200
+ if (!response.ok) {
201
+ throw new Error(await response.text());
202
+ }
203
+ return await response.json();
204
+ }
205
+
206
+ function uploadWithXhr(url, form, uploadState) {
207
+ return new Promise((resolve, reject) => {
208
+ const xhr = new XMLHttpRequest();
209
+ xhr.open("POST", url);
210
+ xhr.upload.addEventListener("progress", (event) => {
211
+ if (event.lengthComputable) {
212
+ showUploadProgress(event.loaded, event.total, "正在发送到服务端...");
213
+ }
214
+ });
215
+ xhr.addEventListener("load", () => {
216
+ if (xhr.status >= 200 && xhr.status < 300) {
217
+ resolve(JSON.parse(xhr.responseText));
218
+ } else {
219
+ reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
220
+ }
221
+ });
222
+ xhr.addEventListener("error", () => reject(new Error("Network error during upload.")));
223
+ xhr.addEventListener("abort", () => reject(new Error("Upload request aborted.")));
224
+ xhr.send(form);
161
225
  });
162
- const body = await response.text();
163
- appendLogLine(response.ok ? `上传完成: ${body}` : `上传失败: ${body}`);
164
- setStatus(response.ok ? "上传完成" : "上传失败");
226
+ }
227
+
228
+ cancelUploadButton.addEventListener("click", async () => {
229
+ if (!activeUpload) return;
230
+ activeUpload.cancelled = true;
231
+ showUploadProgress(0, activeUpload.fileSize, "正在取消上传...");
232
+ if (activeUpload.transferId) {
233
+ await fetch(`/api/transfers/${activeUpload.transferId}`, { method: "DELETE" });
234
+ }
235
+ if (activeUpload.xhr) activeUpload.xhr.abort();
165
236
  });
166
237
 
167
238
  document.querySelector("#download-form").addEventListener("submit", async (event) => {
@@ -390,6 +461,49 @@ function filenameFromResponse(response, fallbackPath) {
390
461
  return fallbackPath.split("/").filter(Boolean).pop() || "download.bin";
391
462
  }
392
463
 
464
+ function showUploadProgress(done, total, message) {
465
+ uploadProgress.classList.remove("hidden");
466
+ const percent = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
467
+ uploadProgressBar.value = percent;
468
+ uploadProgressText.textContent = `${message} ${formatBytes(done)}${total ? ` / ${formatBytes(total)}` : ""}`;
469
+ }
470
+
471
+ function startUploadPolling(uploadState) {
472
+ stopUploadPolling(uploadState);
473
+ uploadState.pollTimer = window.setInterval(async () => {
474
+ if (!uploadState.transferId) return;
475
+ try {
476
+ const response = await fetch(`/api/transfers/${uploadState.transferId}`);
477
+ if (!response.ok) return;
478
+ const status = await response.json();
479
+ showUploadProgress(
480
+ status.bytes_transferred || 0,
481
+ status.total_bytes || uploadState.fileSize,
482
+ status.message || status.state,
483
+ );
484
+ if (["completed", "cancelled", "failed"].includes(status.state)) {
485
+ stopUploadPolling(uploadState);
486
+ }
487
+ } catch (_error) {
488
+ return;
489
+ }
490
+ }, 500);
491
+ }
492
+
493
+ function stopUploadPolling(uploadState) {
494
+ if (uploadState.pollTimer) {
495
+ window.clearInterval(uploadState.pollTimer);
496
+ uploadState.pollTimer = null;
497
+ }
498
+ }
499
+
500
+ function formatBytes(value) {
501
+ if (!Number.isFinite(value)) return "0 B";
502
+ if (value < 1024) return `${value} B`;
503
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
504
+ return `${(value / 1024 / 1024).toFixed(1)} MiB`;
505
+ }
506
+
393
507
  function bytesToBase64(bytes) {
394
508
  let binary = "";
395
509
  const size = 0x8000;
@@ -117,6 +117,13 @@
117
117
  </label>
118
118
  <button type="submit">上传</button>
119
119
  </form>
120
+ <div id="upload-progress" class="transfer-progress hidden">
121
+ <div class="progress-row">
122
+ <span id="upload-progress-text">等待上传</span>
123
+ <button id="cancel-upload" type="button">取消上传</button>
124
+ </div>
125
+ <progress id="upload-progress-bar" max="100" value="0"></progress>
126
+ </div>
120
127
  <form id="download-form" class="stack compact">
121
128
  <label>
122
129
  下载远端路径
@@ -233,6 +233,33 @@ label {
233
233
  text-decoration: none;
234
234
  }
235
235
 
236
+ .transfer-progress {
237
+ display: grid;
238
+ gap: 8px;
239
+ border: 1px solid var(--line);
240
+ border-radius: 6px;
241
+ padding: 10px;
242
+ background: #0c1116;
243
+ }
244
+
245
+ .transfer-progress.hidden {
246
+ display: none;
247
+ }
248
+
249
+ .progress-row {
250
+ display: grid;
251
+ grid-template-columns: minmax(0, 1fr) auto;
252
+ gap: 8px;
253
+ align-items: center;
254
+ color: var(--muted);
255
+ font-size: 12px;
256
+ }
257
+
258
+ progress {
259
+ width: 100%;
260
+ height: 10px;
261
+ }
262
+
236
263
  .workspace {
237
264
  display: grid;
238
265
  grid-template-rows: 52px minmax(0, 1fr);
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import Literal
8
+
9
+
10
+ TransferState = Literal["running", "completed", "cancelled", "failed"]
11
+
12
+
13
+ @dataclass
14
+ class TransferStatus:
15
+ transfer_id: str
16
+ state: TransferState
17
+ bytes_transferred: int
18
+ total_bytes: int | None
19
+ remote_path: str
20
+ message: str
21
+ created_at: datetime
22
+ updated_at: datetime
23
+
24
+
25
+ class TransferTracker:
26
+ def __init__(self, total_bytes: int | None, remote_path: str) -> None:
27
+ now = datetime.now(timezone.utc)
28
+ self.id = str(uuid.uuid4())
29
+ self.cancel_event = threading.Event()
30
+ self._lock = threading.RLock()
31
+ self._status = TransferStatus(
32
+ transfer_id=self.id,
33
+ state="running",
34
+ bytes_transferred=0,
35
+ total_bytes=total_bytes,
36
+ remote_path=remote_path,
37
+ message="Upload started.",
38
+ created_at=now,
39
+ updated_at=now,
40
+ )
41
+
42
+ def update_progress(self, bytes_transferred: int) -> None:
43
+ with self._lock:
44
+ self._status.bytes_transferred = bytes_transferred
45
+ self._status.message = "Uploading."
46
+ self._status.updated_at = datetime.now(timezone.utc)
47
+
48
+ def complete(self, bytes_transferred: int, message: str) -> None:
49
+ with self._lock:
50
+ self._status.state = "completed"
51
+ self._status.bytes_transferred = bytes_transferred
52
+ self._status.message = message
53
+ self._status.updated_at = datetime.now(timezone.utc)
54
+
55
+ def fail(self, message: str) -> None:
56
+ with self._lock:
57
+ self._status.state = "failed"
58
+ self._status.message = message
59
+ self._status.updated_at = datetime.now(timezone.utc)
60
+
61
+ def cancel(self) -> None:
62
+ self.cancel_event.set()
63
+ with self._lock:
64
+ self._status.state = "cancelled"
65
+ self._status.message = "Upload cancellation requested."
66
+ self._status.updated_at = datetime.now(timezone.utc)
67
+
68
+ def cancelled(self, message: str) -> None:
69
+ self.cancel_event.set()
70
+ with self._lock:
71
+ self._status.state = "cancelled"
72
+ self._status.message = message
73
+ self._status.updated_at = datetime.now(timezone.utc)
74
+
75
+ def status(self) -> TransferStatus:
76
+ with self._lock:
77
+ return TransferStatus(**self._status.__dict__)
78
+
79
+
80
+ class TransferManager:
81
+ def __init__(self) -> None:
82
+ self._lock = threading.RLock()
83
+ self._transfers: dict[str, TransferTracker] = {}
84
+
85
+ def create_upload(self, total_bytes: int | None, remote_path: str) -> TransferTracker:
86
+ tracker = TransferTracker(total_bytes=total_bytes, remote_path=remote_path)
87
+ with self._lock:
88
+ self._transfers[tracker.id] = tracker
89
+ return tracker
90
+
91
+ def get(self, transfer_id: str) -> TransferTracker | None:
92
+ with self._lock:
93
+ return self._transfers.get(transfer_id)
94
+
95
+ def cancel(self, transfer_id: str) -> bool:
96
+ tracker = self.get(transfer_id)
97
+ if tracker is None:
98
+ return False
99
+ tracker.cancel()
100
+ return True
@@ -1,189 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import base64
4
- import binascii
5
- import os
6
- import posixpath
7
- import shlex
8
- import uuid
9
- from collections.abc import Iterator
10
- from typing import BinaryIO
11
-
12
- import paramiko
13
-
14
-
15
- class FileTransferError(RuntimeError):
16
- pass
17
-
18
-
19
- def upload_file(
20
- client: paramiko.SSHClient,
21
- source: BinaryIO,
22
- remote_path: str,
23
- size: int | None,
24
- ) -> tuple[str, int]:
25
- try:
26
- return _upload_sftp(client, source, remote_path, size)
27
- except Exception as sftp_exc:
28
- source.seek(0)
29
- try:
30
- return _upload_shell(client, source, remote_path, size)
31
- except Exception as shell_exc:
32
- raise FileTransferError(
33
- f"SFTP upload failed: {sftp_exc}\nShell fallback upload failed: {shell_exc}"
34
- ) from shell_exc
35
-
36
-
37
- def download_file(client: paramiko.SSHClient, remote_path: str) -> tuple[str, Iterator[bytes]]:
38
- try:
39
- return "sftp", _download_sftp(client, remote_path)
40
- except Exception as sftp_exc:
41
- try:
42
- return "shell", _download_shell(client, remote_path)
43
- except Exception as shell_exc:
44
- raise FileTransferError(
45
- f"SFTP download failed: {sftp_exc}\nShell fallback download failed: {shell_exc}"
46
- ) from shell_exc
47
-
48
-
49
- def _upload_sftp(
50
- client: paramiko.SSHClient,
51
- source: BinaryIO,
52
- remote_path: str,
53
- size: int | None,
54
- ) -> tuple[str, int]:
55
- transferred = 0
56
-
57
- def callback(sent: int, _total: int) -> None:
58
- nonlocal transferred
59
- transferred = max(transferred, sent)
60
-
61
- with client.open_sftp() as sftp:
62
- parent = posixpath.dirname(remote_path)
63
- if parent and parent != ".":
64
- _sftp_mkdirs(sftp, parent)
65
- sftp.putfo(source, remote_path, file_size=size or 0, callback=callback)
66
-
67
- if transferred == 0 and size is not None:
68
- transferred = size
69
- elif transferred == 0:
70
- transferred = _remote_size(client, remote_path)
71
- return "sftp", transferred
72
-
73
-
74
- def _sftp_mkdirs(sftp: paramiko.SFTPClient, path: str) -> None:
75
- if path in ("", "/"):
76
- return
77
- parts = [part for part in path.split("/") if part]
78
- current = "/" if path.startswith("/") else ""
79
- for part in parts:
80
- current = posixpath.join(current, part) if current else part
81
- try:
82
- sftp.stat(current)
83
- except IOError:
84
- sftp.mkdir(current)
85
-
86
-
87
- def _upload_shell(
88
- client: paramiko.SSHClient,
89
- source: BinaryIO,
90
- remote_path: str,
91
- size: int | None,
92
- ) -> tuple[str, int]:
93
- remote_dir = posixpath.dirname(remote_path) or "."
94
- temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
95
- command = (
96
- "set -e; "
97
- f"mkdir -p -- {shlex.quote(remote_dir)}; "
98
- f"({_base64_decode_command()}) > {shlex.quote(temp_path)}; "
99
- f"mv -- {shlex.quote(temp_path)} {shlex.quote(remote_path)}"
100
- )
101
- stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
102
- transferred = 0
103
- try:
104
- while True:
105
- chunk = source.read(48 * 1024)
106
- if not chunk:
107
- break
108
- transferred += len(chunk)
109
- stdin.write(base64.b64encode(chunk).decode("ascii"))
110
- stdin.write("\n")
111
- stdin.channel.shutdown_write()
112
- exit_code = stdout.channel.recv_exit_status()
113
- error_text = stderr.read().decode("utf-8", errors="replace")
114
- finally:
115
- stdin.close()
116
- stdout.close()
117
- stderr.close()
118
- if exit_code != 0:
119
- raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
120
- if size is not None and transferred != size:
121
- raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
122
- return "shell", transferred
123
-
124
-
125
- def _download_sftp(client: paramiko.SSHClient, remote_path: str) -> Iterator[bytes]:
126
- sftp = client.open_sftp()
127
- remote_file = sftp.open(remote_path, "rb")
128
-
129
- def iterator() -> Iterator[bytes]:
130
- try:
131
- while True:
132
- chunk = remote_file.read(64 * 1024)
133
- if not chunk:
134
- break
135
- yield chunk
136
- finally:
137
- remote_file.close()
138
- sftp.close()
139
-
140
- return iterator()
141
-
142
-
143
- def _download_shell(client: paramiko.SSHClient, remote_path: str) -> Iterator[bytes]:
144
- command = f"base64 < {shlex.quote(remote_path)}"
145
- stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
146
- stdin.close()
147
-
148
- def iterator() -> Iterator[bytes]:
149
- buffered = b""
150
- try:
151
- while True:
152
- chunk = stdout.channel.recv(64 * 1024)
153
- if not chunk:
154
- break
155
- buffered += b"".join(chunk.split())
156
- keep = len(buffered) % 4
157
- ready = buffered[:-keep] if keep else buffered
158
- buffered = buffered[-keep:] if keep else b""
159
- if ready:
160
- yield base64.b64decode(ready)
161
- if buffered:
162
- yield base64.b64decode(buffered)
163
- exit_code = stdout.channel.recv_exit_status()
164
- error_text = stderr.read().decode("utf-8", errors="replace")
165
- if exit_code != 0:
166
- raise FileTransferError(
167
- f"Remote download command failed with exit code {exit_code}: {error_text}"
168
- )
169
- except binascii.Error as exc:
170
- raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
171
- finally:
172
- stdout.close()
173
- stderr.close()
174
-
175
- return iterator()
176
-
177
-
178
- def _base64_decode_command() -> str:
179
- return "base64 -d 2>/dev/null || base64 -D"
180
-
181
-
182
- def _remote_size(client: paramiko.SSHClient, remote_path: str) -> int:
183
- with client.open_sftp() as sftp:
184
- return int(sftp.stat(remote_path).st_size or 0)
185
-
186
-
187
- def filename_for_download(remote_path: str) -> str:
188
- name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
189
- return os.path.basename(name)
File without changes
File without changes
File without changes
File without changes