sshler 0.3.2__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.
- sshler/__init__.py +10 -0
- sshler/cli.py +161 -0
- sshler/config.py +425 -0
- sshler/scripts/install-sshler-task.ps1 +28 -0
- sshler/scripts/remove-sshler-task.ps1 +15 -0
- sshler/scripts/run-sshler.ps1 +24 -0
- sshler/ssh.py +329 -0
- sshler/ssh_config.py +134 -0
- sshler/state.py +230 -0
- sshler/static/base.js +305 -0
- sshler/static/favicon-terminal.svg +8 -0
- sshler/static/favicon.svg +8 -0
- sshler/static/file-edit.js +81 -0
- sshler/static/file-view.js +60 -0
- sshler/static/style.css +158 -0
- sshler/static/term.js +304 -0
- sshler/templates/base.html +41 -0
- sshler/templates/box.html +53 -0
- sshler/templates/docs.html +40 -0
- sshler/templates/file_edit.html +30 -0
- sshler/templates/file_view.html +31 -0
- sshler/templates/index.html +49 -0
- sshler/templates/new_box.html +42 -0
- sshler/templates/partials/dir_listing.html +91 -0
- sshler/templates/term.html +67 -0
- sshler/webapp.py +1897 -0
- sshler-0.3.2.dist-info/METADATA +245 -0
- sshler-0.3.2.dist-info/RECORD +31 -0
- sshler-0.3.2.dist-info/WHEEL +5 -0
- sshler-0.3.2.dist-info/entry_points.txt +2 -0
- sshler-0.3.2.dist-info/top_level.txt +1 -0
sshler/webapp.py
ADDED
|
@@ -0,0 +1,1897 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
import asyncssh
|
|
14
|
+
from fastapi import (
|
|
15
|
+
Depends,
|
|
16
|
+
FastAPI,
|
|
17
|
+
File,
|
|
18
|
+
Form,
|
|
19
|
+
HTTPException,
|
|
20
|
+
Query,
|
|
21
|
+
Request,
|
|
22
|
+
UploadFile,
|
|
23
|
+
WebSocket,
|
|
24
|
+
WebSocketDisconnect,
|
|
25
|
+
)
|
|
26
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
27
|
+
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
|
|
28
|
+
from fastapi.staticfiles import StaticFiles
|
|
29
|
+
from fastapi.templating import Jinja2Templates
|
|
30
|
+
|
|
31
|
+
from . import __version__, state
|
|
32
|
+
from .config import (
|
|
33
|
+
AppConfig,
|
|
34
|
+
StoredBox,
|
|
35
|
+
find_box,
|
|
36
|
+
get_config_path,
|
|
37
|
+
load_config,
|
|
38
|
+
rebuild_boxes,
|
|
39
|
+
save_config,
|
|
40
|
+
)
|
|
41
|
+
from .ssh import (
|
|
42
|
+
SSHError,
|
|
43
|
+
connect,
|
|
44
|
+
open_tmux,
|
|
45
|
+
sftp_is_directory,
|
|
46
|
+
sftp_list_directory,
|
|
47
|
+
sftp_read_file,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
51
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
52
|
+
|
|
53
|
+
DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
54
|
+
MAX_IMAGE_PREVIEW_BYTES = 2 * 1024 * 1024
|
|
55
|
+
IMAGE_CONTENT_TYPES: dict[str, str] = {
|
|
56
|
+
".png": "image/png",
|
|
57
|
+
".jpg": "image/jpeg",
|
|
58
|
+
".jpeg": "image/jpeg",
|
|
59
|
+
".gif": "image/gif",
|
|
60
|
+
".webp": "image/webp",
|
|
61
|
+
".svg": "image/svg+xml",
|
|
62
|
+
}
|
|
63
|
+
LOCAL_IS_WINDOWS = platform.system().lower().startswith("windows")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ServerSettings:
|
|
68
|
+
allow_origins: list[str] = field(default_factory=list)
|
|
69
|
+
csrf_token: str | None = field(default_factory=lambda: secrets.token_urlsafe(32))
|
|
70
|
+
max_upload_bytes: int = DEFAULT_MAX_UPLOAD_BYTES
|
|
71
|
+
allow_ssh_alias: bool = True
|
|
72
|
+
basic_auth: tuple[str, str] | None = None
|
|
73
|
+
basic_auth_header: str | None = field(init=False, default=None)
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
# normalise origins without trailing slashes and drop duplicates while preserving order
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
normalised: list[str] = []
|
|
79
|
+
for origin in self.allow_origins:
|
|
80
|
+
cleaned = origin.rstrip("/")
|
|
81
|
+
if cleaned and cleaned not in seen:
|
|
82
|
+
seen.add(cleaned)
|
|
83
|
+
normalised.append(cleaned)
|
|
84
|
+
self.allow_origins = normalised
|
|
85
|
+
|
|
86
|
+
if self.basic_auth:
|
|
87
|
+
user, password = self.basic_auth
|
|
88
|
+
raw = f"{user}:{password}".encode()
|
|
89
|
+
self.basic_auth_header = "Basic " + base64.b64encode(raw).decode("ascii")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _normalize_directory_path(directory: str | None) -> str:
|
|
94
|
+
base = PurePosixPath(directory or "/")
|
|
95
|
+
if not base.is_absolute():
|
|
96
|
+
base = PurePosixPath("/") / base
|
|
97
|
+
normalized = base.as_posix()
|
|
98
|
+
return normalized or "/"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _compose_remote_child_path(directory: str, filename: str) -> str:
|
|
102
|
+
cleaned = (filename or "").strip()
|
|
103
|
+
if not cleaned:
|
|
104
|
+
raise ValueError("Filename is required")
|
|
105
|
+
if cleaned in {".", ".."} or "/" in cleaned:
|
|
106
|
+
raise ValueError("Filename cannot contain path separators")
|
|
107
|
+
if "\x00" in cleaned:
|
|
108
|
+
raise ValueError("Filename contains unsupported characters")
|
|
109
|
+
parent = PurePosixPath(_normalize_directory_path(directory))
|
|
110
|
+
return (parent / cleaned).as_posix()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalize_local_path(directory: str | None) -> str:
|
|
114
|
+
if directory:
|
|
115
|
+
base = Path(directory).expanduser()
|
|
116
|
+
else:
|
|
117
|
+
base = Path.home()
|
|
118
|
+
try:
|
|
119
|
+
resolved = base.resolve()
|
|
120
|
+
except Exception:
|
|
121
|
+
resolved = base
|
|
122
|
+
if LOCAL_IS_WINDOWS:
|
|
123
|
+
return resolved.as_posix()
|
|
124
|
+
return str(resolved)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _compose_local_child_path(directory: str, filename: str) -> str:
|
|
128
|
+
cleaned = (filename or "").strip()
|
|
129
|
+
if not cleaned:
|
|
130
|
+
raise ValueError("Filename is required")
|
|
131
|
+
if cleaned in {".", ".."} or any(sep in cleaned for sep in ("/", "\\")):
|
|
132
|
+
raise ValueError("Filename cannot contain path separators")
|
|
133
|
+
parent = Path(directory or Path.home())
|
|
134
|
+
target = parent / cleaned
|
|
135
|
+
if LOCAL_IS_WINDOWS:
|
|
136
|
+
return target.expanduser().as_posix()
|
|
137
|
+
return str(target.expanduser())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def _local_list_directory(path: str) -> list[dict[str, object]]:
|
|
141
|
+
def _worker() -> list[dict[str, object]]:
|
|
142
|
+
entries: list[dict[str, object]] = []
|
|
143
|
+
base_path = Path(path)
|
|
144
|
+
for child in base_path.iterdir():
|
|
145
|
+
try:
|
|
146
|
+
stats = child.stat()
|
|
147
|
+
except Exception:
|
|
148
|
+
continue
|
|
149
|
+
entries.append(
|
|
150
|
+
{
|
|
151
|
+
"name": child.name,
|
|
152
|
+
"is_directory": child.is_dir(),
|
|
153
|
+
"size": stats.st_size if child.is_file() else None,
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
entries.sort(key=lambda entry: (not entry["is_directory"], entry["name"].lower()))
|
|
157
|
+
return entries
|
|
158
|
+
|
|
159
|
+
return await asyncio.to_thread(_worker)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _local_is_directory(path: str) -> bool:
|
|
163
|
+
return await asyncio.to_thread(lambda: Path(path).is_dir())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _local_read_text(path: str, max_bytes: int) -> str:
|
|
167
|
+
def _worker() -> str:
|
|
168
|
+
with open(Path(path), "rb") as handle:
|
|
169
|
+
data = handle.read(max_bytes)
|
|
170
|
+
return data.decode("utf-8", errors="replace")
|
|
171
|
+
|
|
172
|
+
return await asyncio.to_thread(_worker)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def _local_write_text(path: str, content: str) -> None:
|
|
176
|
+
def _worker() -> None:
|
|
177
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
with open(Path(path), "w", encoding="utf-8") as handle:
|
|
179
|
+
handle.write(content)
|
|
180
|
+
|
|
181
|
+
await asyncio.to_thread(_worker)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _local_read_bytes(path: str, limit: int) -> tuple[bytes, bool]:
|
|
185
|
+
def _worker() -> tuple[bytes, bool]:
|
|
186
|
+
with open(Path(path), "rb") as handle:
|
|
187
|
+
data = handle.read(limit + 1)
|
|
188
|
+
return (data[:limit], len(data) > limit)
|
|
189
|
+
|
|
190
|
+
return await asyncio.to_thread(_worker)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def _local_write_bytes(path: str, data: bytes) -> None:
|
|
194
|
+
def _worker() -> None:
|
|
195
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
with open(Path(path), "wb") as handle:
|
|
197
|
+
handle.write(data)
|
|
198
|
+
|
|
199
|
+
await asyncio.to_thread(_worker)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def _local_create_file(path: str) -> None:
|
|
203
|
+
def _worker() -> None:
|
|
204
|
+
file_path = Path(path)
|
|
205
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
file_path.touch(exist_ok=False)
|
|
207
|
+
|
|
208
|
+
await asyncio.to_thread(_worker)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _local_delete_file(path: str) -> None:
|
|
212
|
+
def _worker() -> None:
|
|
213
|
+
Path(path).unlink()
|
|
214
|
+
|
|
215
|
+
await asyncio.to_thread(_worker)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _local_tmux_base_command() -> list[str]:
|
|
219
|
+
if LOCAL_IS_WINDOWS:
|
|
220
|
+
return ["wsl", "--", "tmux"]
|
|
221
|
+
return ["tmux"]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def _convert_path_to_wsl(path: str) -> str | None:
|
|
225
|
+
if not LOCAL_IS_WINDOWS:
|
|
226
|
+
return path
|
|
227
|
+
|
|
228
|
+
def _worker() -> str | None:
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
["wsl", "wslpath", "-a", path],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
check=False,
|
|
234
|
+
)
|
|
235
|
+
if result.returncode != 0:
|
|
236
|
+
return None
|
|
237
|
+
return result.stdout.strip()
|
|
238
|
+
|
|
239
|
+
return await asyncio.to_thread(_worker)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _open_local_tmux(
|
|
243
|
+
working_directory: str,
|
|
244
|
+
session: str,
|
|
245
|
+
) -> asyncio.subprocess.Process:
|
|
246
|
+
command = list(_local_tmux_base_command()) + ["new", "-As", session]
|
|
247
|
+
if working_directory:
|
|
248
|
+
target_dir = working_directory
|
|
249
|
+
if LOCAL_IS_WINDOWS:
|
|
250
|
+
converted = await _convert_path_to_wsl(working_directory)
|
|
251
|
+
if converted:
|
|
252
|
+
target_dir = converted
|
|
253
|
+
command.extend(["-c", target_dir])
|
|
254
|
+
|
|
255
|
+
# On Windows, we need to use winpty or conpty to provide a PTY for tmux
|
|
256
|
+
# For now, let's try using script to provide a PTY
|
|
257
|
+
if LOCAL_IS_WINDOWS:
|
|
258
|
+
# Use 'script' command in WSL to create a PTY
|
|
259
|
+
script_command = ["wsl", "--", "script", "-qfc", " ".join(f"'{arg}'" if " " in arg else arg for arg in command[2:]), "/dev/null"]
|
|
260
|
+
return await asyncio.create_subprocess_exec(
|
|
261
|
+
*script_command,
|
|
262
|
+
stdin=asyncio.subprocess.PIPE,
|
|
263
|
+
stdout=asyncio.subprocess.PIPE,
|
|
264
|
+
stderr=asyncio.subprocess.PIPE,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return await asyncio.create_subprocess_exec(
|
|
268
|
+
*command,
|
|
269
|
+
stdin=asyncio.subprocess.PIPE,
|
|
270
|
+
stdout=asyncio.subprocess.PIPE,
|
|
271
|
+
stderr=asyncio.subprocess.PIPE,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _run_local_tmux_command(args: list[str]) -> None:
|
|
276
|
+
command = list(_local_tmux_base_command()) + args
|
|
277
|
+
try:
|
|
278
|
+
process = await asyncio.create_subprocess_exec(
|
|
279
|
+
*command,
|
|
280
|
+
stdout=asyncio.subprocess.PIPE,
|
|
281
|
+
stderr=asyncio.subprocess.PIPE,
|
|
282
|
+
)
|
|
283
|
+
await process.communicate()
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def _list_local_tmux_windows(session: str) -> list[dict[str, str]] | None:
|
|
289
|
+
command = list(_local_tmux_base_command()) + [
|
|
290
|
+
"list-windows",
|
|
291
|
+
"-F",
|
|
292
|
+
"#{window_index} #{window_name} #{window_active}",
|
|
293
|
+
"-t",
|
|
294
|
+
session,
|
|
295
|
+
]
|
|
296
|
+
try:
|
|
297
|
+
process = await asyncio.create_subprocess_exec(
|
|
298
|
+
*command,
|
|
299
|
+
stdout=asyncio.subprocess.PIPE,
|
|
300
|
+
stderr=asyncio.subprocess.PIPE,
|
|
301
|
+
)
|
|
302
|
+
stdout, _ = await process.communicate()
|
|
303
|
+
except Exception:
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
if process.returncode != 0:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
windows: list[dict[str, str]] = []
|
|
310
|
+
for line in stdout.decode("utf-8", errors="ignore").splitlines():
|
|
311
|
+
parts = line.split(" ", 2)
|
|
312
|
+
if len(parts) < 3:
|
|
313
|
+
continue
|
|
314
|
+
index, name, active = parts
|
|
315
|
+
windows.append({"index": index, "name": name, "active": active == "1"})
|
|
316
|
+
return windows
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def make_app(settings: ServerSettings | None = None) -> FastAPI:
|
|
320
|
+
"""Create and configure the FastAPI application.
|
|
321
|
+
|
|
322
|
+
English:
|
|
323
|
+
Applies security middleware, template globals, and route wiring. A
|
|
324
|
+
:class:`ServerSettings` instance controls auth, CORS, upload limits, and
|
|
325
|
+
alias resolution.
|
|
326
|
+
|
|
327
|
+
日本語:
|
|
328
|
+
セキュリティミドルウェアやテンプレート変数、各種ルートを設定します。
|
|
329
|
+
:class:`ServerSettings` により認証や CORS、アップロード上限、エイリアス解決
|
|
330
|
+
を制御します。
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
FastAPI: Configured ASGI application.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
settings = settings or ServerSettings()
|
|
337
|
+
|
|
338
|
+
application = FastAPI(title="sshler", version="0.1.0")
|
|
339
|
+
application.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
340
|
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
341
|
+
app_version = _compute_app_version()
|
|
342
|
+
|
|
343
|
+
application.state.settings = settings
|
|
344
|
+
templates.env.globals["csrf_token"] = settings.csrf_token
|
|
345
|
+
|
|
346
|
+
if settings.allow_origins:
|
|
347
|
+
application.add_middleware(
|
|
348
|
+
CORSMiddleware,
|
|
349
|
+
allow_origins=settings.allow_origins,
|
|
350
|
+
allow_credentials=True,
|
|
351
|
+
allow_methods=["GET", "POST", "OPTIONS"],
|
|
352
|
+
allow_headers=["*"]
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
@application.middleware("http")
|
|
356
|
+
async def _security_middleware(request: Request, call_next):
|
|
357
|
+
if settings.basic_auth_header and request.method != "OPTIONS":
|
|
358
|
+
auth_header = request.headers.get("authorization")
|
|
359
|
+
if auth_header != settings.basic_auth_header:
|
|
360
|
+
return Response(
|
|
361
|
+
status_code=401,
|
|
362
|
+
headers={"WWW-Authenticate": 'Basic realm="sshler"'},
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
response = await call_next(request)
|
|
366
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
367
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
368
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
369
|
+
csp_directives = [
|
|
370
|
+
"default-src 'self'",
|
|
371
|
+
"img-src 'self' data:",
|
|
372
|
+
"style-src 'self' 'unsafe-inline' https://unpkg.com",
|
|
373
|
+
"script-src 'self' https://unpkg.com",
|
|
374
|
+
"connect-src 'self' https://unpkg.com",
|
|
375
|
+
]
|
|
376
|
+
response.headers["Content-Security-Policy"] = "; ".join(csp_directives) + ";"
|
|
377
|
+
return response
|
|
378
|
+
|
|
379
|
+
def _require_token(request: Request) -> None:
|
|
380
|
+
if not settings.csrf_token:
|
|
381
|
+
return
|
|
382
|
+
supplied = request.headers.get("x-sshler-token")
|
|
383
|
+
if supplied != settings.csrf_token:
|
|
384
|
+
raise HTTPException(status_code=403, detail="Missing or invalid X-SSHLER-TOKEN header")
|
|
385
|
+
|
|
386
|
+
def _build_directory_urls(request: Request, box_name: str) -> dict[str, str]:
|
|
387
|
+
def _resolve(endpoint: str, **params: str) -> str:
|
|
388
|
+
try:
|
|
389
|
+
return request.url_for(endpoint, **params)
|
|
390
|
+
except Exception:
|
|
391
|
+
if endpoint == "list_directory":
|
|
392
|
+
return f"/box/{box_name}/ls"
|
|
393
|
+
if endpoint == "create_empty_file":
|
|
394
|
+
return f"/box/{box_name}/touch"
|
|
395
|
+
if endpoint == "upload_file":
|
|
396
|
+
return f"/box/{box_name}/upload"
|
|
397
|
+
if endpoint == "toggle_favorite":
|
|
398
|
+
return f"/box/{box_name}/fav"
|
|
399
|
+
if endpoint == "view_file":
|
|
400
|
+
return f"/box/{box_name}/cat"
|
|
401
|
+
if endpoint == "edit_file":
|
|
402
|
+
return f"/box/{box_name}/edit"
|
|
403
|
+
if endpoint == "delete_file":
|
|
404
|
+
return f"/box/{box_name}/delete"
|
|
405
|
+
if endpoint == "term_page":
|
|
406
|
+
return "/term"
|
|
407
|
+
return "/"
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
"list": _resolve("list_directory", name=box_name),
|
|
411
|
+
"touch": _resolve("create_empty_file", name=box_name),
|
|
412
|
+
"upload": _resolve("upload_file", name=box_name),
|
|
413
|
+
"favorite": _resolve("toggle_favorite", name=box_name),
|
|
414
|
+
"preview": _resolve("view_file", name=box_name),
|
|
415
|
+
"edit": _resolve("edit_file", name=box_name),
|
|
416
|
+
"delete": _resolve("delete_file", name=box_name),
|
|
417
|
+
"term": _resolve("term_page"),
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async def _render_directory_listing(
|
|
421
|
+
request: Request,
|
|
422
|
+
box,
|
|
423
|
+
directory_path: str,
|
|
424
|
+
target_id: str,
|
|
425
|
+
application_config: AppConfig,
|
|
426
|
+
error_override: str | None = None,
|
|
427
|
+
) -> HTMLResponse:
|
|
428
|
+
if box.transport == "local":
|
|
429
|
+
normalized_directory = _normalize_local_path(directory_path)
|
|
430
|
+
try:
|
|
431
|
+
entries = await _local_list_directory(normalized_directory)
|
|
432
|
+
error_message = error_override
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
entries = []
|
|
435
|
+
error_message = error_override or f"Directory listing failed: {exc}"
|
|
436
|
+
|
|
437
|
+
context = {
|
|
438
|
+
"request": request,
|
|
439
|
+
"box": box,
|
|
440
|
+
"directory_path": normalized_directory,
|
|
441
|
+
"entries": entries,
|
|
442
|
+
"error": error_message,
|
|
443
|
+
"target_id": target_id,
|
|
444
|
+
"urls": _build_directory_urls(request, box.name),
|
|
445
|
+
}
|
|
446
|
+
return templates.TemplateResponse(request, "partials/dir_listing.html", context)
|
|
447
|
+
|
|
448
|
+
normalized_directory = _normalize_directory_path(directory_path)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
connection = await connect(
|
|
452
|
+
box.connect_host,
|
|
453
|
+
box.user,
|
|
454
|
+
box.port,
|
|
455
|
+
box.keyfile,
|
|
456
|
+
box.known_hosts,
|
|
457
|
+
application_config.ssh_config_path,
|
|
458
|
+
box.ssh_alias,
|
|
459
|
+
allow_alias=settings.allow_ssh_alias,
|
|
460
|
+
)
|
|
461
|
+
except SSHError as exc:
|
|
462
|
+
context = {
|
|
463
|
+
"request": request,
|
|
464
|
+
"box": box,
|
|
465
|
+
"directory_path": normalized_directory,
|
|
466
|
+
"entries": [],
|
|
467
|
+
"error": error_override or f"SSH connection failed: {exc}",
|
|
468
|
+
"target_id": target_id,
|
|
469
|
+
"urls": _build_directory_urls(request, box.name),
|
|
470
|
+
}
|
|
471
|
+
return templates.TemplateResponse(request, "partials/dir_listing.html", context)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
try:
|
|
475
|
+
entries = await sftp_list_directory(connection, normalized_directory)
|
|
476
|
+
error_message = error_override
|
|
477
|
+
except Exception as exc: # pragma: no cover - remote SFTP failures vary by host
|
|
478
|
+
entries = []
|
|
479
|
+
error_message = error_override or f"Directory listing failed: {exc}"
|
|
480
|
+
context = {
|
|
481
|
+
"request": request,
|
|
482
|
+
"box": box,
|
|
483
|
+
"directory_path": normalized_directory,
|
|
484
|
+
"entries": entries,
|
|
485
|
+
"error": error_message,
|
|
486
|
+
"target_id": target_id,
|
|
487
|
+
"urls": _build_directory_urls(request, box.name),
|
|
488
|
+
}
|
|
489
|
+
return templates.TemplateResponse(request, "partials/dir_listing.html", context)
|
|
490
|
+
finally:
|
|
491
|
+
connection.close()
|
|
492
|
+
|
|
493
|
+
def _get_application_config() -> AppConfig:
|
|
494
|
+
"""Dependency that loads the persisted configuration.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
AppConfig: Configuration loaded from disk.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
return load_config()
|
|
501
|
+
|
|
502
|
+
@application.get("/")
|
|
503
|
+
async def root() -> RedirectResponse:
|
|
504
|
+
"""Redirect the index page to the boxes list.
|
|
505
|
+
|
|
506
|
+
English:
|
|
507
|
+
Visiting ``/`` immediately sends the browser to ``/boxes``.
|
|
508
|
+
|
|
509
|
+
日本語:
|
|
510
|
+
ルート ``/`` にアクセスした際に ``/boxes`` へリダイレクトします。
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
RedirectResponse: HTTP redirect to ``/boxes``.
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
return RedirectResponse(url="/boxes")
|
|
517
|
+
|
|
518
|
+
@application.get("/docs", response_class=HTMLResponse)
|
|
519
|
+
async def docs(request: Request) -> HTMLResponse:
|
|
520
|
+
"""Render simple usage documentation.
|
|
521
|
+
|
|
522
|
+
English:
|
|
523
|
+
Serves the built-in help page explaining basic operations.
|
|
524
|
+
|
|
525
|
+
日本語:
|
|
526
|
+
基本的な使い方を説明する内蔵ドキュメントページを表示します。
|
|
527
|
+
"""
|
|
528
|
+
|
|
529
|
+
return templates.TemplateResponse(
|
|
530
|
+
"docs.html",
|
|
531
|
+
{"request": request, "app_version": app_version},
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
@application.get("/boxes", response_class=HTMLResponse)
|
|
535
|
+
async def boxes(
|
|
536
|
+
request: Request,
|
|
537
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
538
|
+
) -> HTMLResponse:
|
|
539
|
+
"""Render the list of configured boxes.
|
|
540
|
+
|
|
541
|
+
English:
|
|
542
|
+
Shows local and remote boxes, including metadata such as favourites
|
|
543
|
+
and default directories.
|
|
544
|
+
|
|
545
|
+
日本語:
|
|
546
|
+
ローカルおよびリモートのボックス一覧と、そのお気に入りやデフォルト
|
|
547
|
+
ディレクトリなどの情報を表示します。
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
request: Incoming HTTP request.
|
|
551
|
+
application_config: Configuration loaded from disk.
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
HTMLResponse: Rendered home page.
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
configuration_path = str(get_config_path())
|
|
558
|
+
context = {
|
|
559
|
+
"configuration": application_config,
|
|
560
|
+
"configuration_path": configuration_path,
|
|
561
|
+
"app_version": app_version,
|
|
562
|
+
}
|
|
563
|
+
return templates.TemplateResponse(request, "index.html", context)
|
|
564
|
+
|
|
565
|
+
@application.get("/box/{name}", response_class=HTMLResponse)
|
|
566
|
+
async def box_page(
|
|
567
|
+
name: str,
|
|
568
|
+
request: Request,
|
|
569
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
570
|
+
) -> HTMLResponse:
|
|
571
|
+
"""Render the detail page for a single box.
|
|
572
|
+
|
|
573
|
+
English:
|
|
574
|
+
Displays favourites and directory browser for the selected box.
|
|
575
|
+
|
|
576
|
+
日本語:
|
|
577
|
+
選択したボックスの詳細ページを表示し、お気に入りやディレクトリブラウザを
|
|
578
|
+
操作できるようにします。
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
name: Box identifier from the URL.
|
|
582
|
+
request: Incoming HTTP request.
|
|
583
|
+
application_config: Configuration loaded from disk.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
HTMLResponse: Rendered page for the chosen box.
|
|
587
|
+
|
|
588
|
+
Raises:
|
|
589
|
+
HTTPException: When the requested box does not exist.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
box = find_box(application_config, name)
|
|
593
|
+
if not box:
|
|
594
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
595
|
+
if getattr(box, "transport", "ssh") == "local":
|
|
596
|
+
base_directory = _normalize_local_path(box.default_dir)
|
|
597
|
+
else:
|
|
598
|
+
base_directory = box.default_dir or f"/home/{box.user}"
|
|
599
|
+
context = {"box": box, "base_directory": base_directory, "app_version": app_version}
|
|
600
|
+
return templates.TemplateResponse(request, "box.html", context)
|
|
601
|
+
|
|
602
|
+
@application.get("/box/{name}/ls", response_class=HTMLResponse)
|
|
603
|
+
async def list_directory(
|
|
604
|
+
name: str,
|
|
605
|
+
request: Request,
|
|
606
|
+
directory_path: str = Query(..., alias="path"),
|
|
607
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
608
|
+
) -> HTMLResponse:
|
|
609
|
+
"""Render a partial listing for a directory.
|
|
610
|
+
|
|
611
|
+
English:
|
|
612
|
+
Produces the table fragment used by HTMX for both SSH and local
|
|
613
|
+
transports.
|
|
614
|
+
|
|
615
|
+
日本語:
|
|
616
|
+
HTMX が利用するディレクトリ一覧の部分テンプレートを生成します。SSH と
|
|
617
|
+
ローカルの両方に対応します。
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
name: Box identifier from the URL.
|
|
621
|
+
request: Incoming HTTP request.
|
|
622
|
+
directory_path: Absolute path to list on the remote host.
|
|
623
|
+
application_config: Configuration loaded from disk.
|
|
624
|
+
|
|
625
|
+
Returns:
|
|
626
|
+
HTMLResponse: HTML fragment containing the directory table.
|
|
627
|
+
|
|
628
|
+
Raises:
|
|
629
|
+
HTTPException: When the requested box does not exist.
|
|
630
|
+
"""
|
|
631
|
+
|
|
632
|
+
box = find_box(application_config, name)
|
|
633
|
+
if not box:
|
|
634
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
635
|
+
|
|
636
|
+
target_id = request.query_params.get("target", "browser")
|
|
637
|
+
|
|
638
|
+
return await _render_directory_listing(
|
|
639
|
+
request,
|
|
640
|
+
box,
|
|
641
|
+
directory_path,
|
|
642
|
+
target_id,
|
|
643
|
+
application_config,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
@application.post("/box/{name}/touch", response_class=HTMLResponse)
|
|
647
|
+
async def create_empty_file(
|
|
648
|
+
name: str,
|
|
649
|
+
request: Request,
|
|
650
|
+
directory: str = Form(...),
|
|
651
|
+
filename: str = Form(...),
|
|
652
|
+
target: str = Form("browser"),
|
|
653
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
654
|
+
) -> HTMLResponse:
|
|
655
|
+
box = find_box(application_config, name)
|
|
656
|
+
if not box:
|
|
657
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
658
|
+
|
|
659
|
+
_require_token(request)
|
|
660
|
+
|
|
661
|
+
directory_path = (
|
|
662
|
+
_normalize_local_path(directory)
|
|
663
|
+
if box.transport == "local"
|
|
664
|
+
else _normalize_directory_path(directory)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
if box.transport == "local":
|
|
668
|
+
try:
|
|
669
|
+
target_path = _compose_local_child_path(directory_path, filename)
|
|
670
|
+
except ValueError as exc:
|
|
671
|
+
return await _render_directory_listing(
|
|
672
|
+
request,
|
|
673
|
+
box,
|
|
674
|
+
directory_path,
|
|
675
|
+
target,
|
|
676
|
+
application_config,
|
|
677
|
+
error_override=f"Invalid filename: {exc}",
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
error_message = None
|
|
681
|
+
success_message: str | None = None
|
|
682
|
+
try:
|
|
683
|
+
await _local_create_file(target_path)
|
|
684
|
+
success_message = f"Created {Path(target_path).name}"
|
|
685
|
+
except FileExistsError:
|
|
686
|
+
error_message = f"File already exists: {Path(target_path).name}"
|
|
687
|
+
except Exception as exc:
|
|
688
|
+
error_message = f"Failed to create file: {exc}"
|
|
689
|
+
|
|
690
|
+
response = await _render_directory_listing(
|
|
691
|
+
request,
|
|
692
|
+
box,
|
|
693
|
+
directory_path,
|
|
694
|
+
target,
|
|
695
|
+
application_config,
|
|
696
|
+
error_override=error_message,
|
|
697
|
+
)
|
|
698
|
+
message = error_message or success_message
|
|
699
|
+
if message:
|
|
700
|
+
trigger_payload = json.dumps(
|
|
701
|
+
{
|
|
702
|
+
"dir-action": {
|
|
703
|
+
"status": "error" if error_message else "success",
|
|
704
|
+
"message": message,
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
709
|
+
return response
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
remote_path = _compose_remote_child_path(directory_path, filename)
|
|
713
|
+
except ValueError as exc:
|
|
714
|
+
return await _render_directory_listing(
|
|
715
|
+
request,
|
|
716
|
+
box,
|
|
717
|
+
directory_path,
|
|
718
|
+
target,
|
|
719
|
+
application_config,
|
|
720
|
+
error_override=f"Invalid filename: {exc}",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
error_message = None
|
|
724
|
+
connection = None
|
|
725
|
+
sftp_client = None
|
|
726
|
+
success_message: str | None = None
|
|
727
|
+
try:
|
|
728
|
+
connection = await connect(
|
|
729
|
+
box.connect_host,
|
|
730
|
+
box.user,
|
|
731
|
+
box.port,
|
|
732
|
+
box.keyfile,
|
|
733
|
+
box.known_hosts,
|
|
734
|
+
application_config.ssh_config_path,
|
|
735
|
+
box.ssh_alias,
|
|
736
|
+
allow_alias=settings.allow_ssh_alias,
|
|
737
|
+
)
|
|
738
|
+
try:
|
|
739
|
+
sftp_client = await connection.start_sftp_client()
|
|
740
|
+
except Exception as exc: # pragma: no cover - depends on remote server
|
|
741
|
+
error_message = f"SFTP session failed: {exc}"
|
|
742
|
+
else:
|
|
743
|
+
try:
|
|
744
|
+
try:
|
|
745
|
+
await sftp_client.stat(remote_path)
|
|
746
|
+
except Exception:
|
|
747
|
+
pass
|
|
748
|
+
else:
|
|
749
|
+
error_message = (
|
|
750
|
+
f"File already exists: {PurePosixPath(remote_path).name}"
|
|
751
|
+
)
|
|
752
|
+
if error_message is None:
|
|
753
|
+
async with await sftp_client.open(
|
|
754
|
+
remote_path, "w", encoding="utf-8"
|
|
755
|
+
) as remote_file:
|
|
756
|
+
await remote_file.write("")
|
|
757
|
+
success_message = f"Created {PurePosixPath(remote_path).name}"
|
|
758
|
+
except Exception as exc: # pragma: no cover - remote host behavior varies
|
|
759
|
+
error_message = f"Failed to create file: {exc}"
|
|
760
|
+
except SSHError as exc:
|
|
761
|
+
error_message = f"SSH connection failed: {exc}"
|
|
762
|
+
finally:
|
|
763
|
+
if sftp_client is not None:
|
|
764
|
+
try:
|
|
765
|
+
await sftp_client.exit()
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
if connection is not None:
|
|
769
|
+
connection.close()
|
|
770
|
+
|
|
771
|
+
response = await _render_directory_listing(
|
|
772
|
+
request,
|
|
773
|
+
box,
|
|
774
|
+
directory_path,
|
|
775
|
+
target,
|
|
776
|
+
application_config,
|
|
777
|
+
error_override=error_message,
|
|
778
|
+
)
|
|
779
|
+
message = error_message or success_message
|
|
780
|
+
if message:
|
|
781
|
+
trigger_payload = json.dumps(
|
|
782
|
+
{
|
|
783
|
+
"dir-action": {
|
|
784
|
+
"status": "error" if error_message else "success",
|
|
785
|
+
"message": message,
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
)
|
|
789
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
790
|
+
return response
|
|
791
|
+
|
|
792
|
+
@application.post("/box/{name}/upload", response_class=HTMLResponse)
|
|
793
|
+
async def upload_file(
|
|
794
|
+
name: str,
|
|
795
|
+
request: Request,
|
|
796
|
+
directory: str = Form(...),
|
|
797
|
+
target: str = Form("browser"),
|
|
798
|
+
file: UploadFile = File(...),
|
|
799
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
800
|
+
) -> HTMLResponse:
|
|
801
|
+
box = find_box(application_config, name)
|
|
802
|
+
if not box:
|
|
803
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
804
|
+
|
|
805
|
+
_require_token(request)
|
|
806
|
+
|
|
807
|
+
directory_path = (
|
|
808
|
+
_normalize_local_path(directory)
|
|
809
|
+
if box.transport == "local"
|
|
810
|
+
else _normalize_directory_path(directory)
|
|
811
|
+
)
|
|
812
|
+
original_name = file.filename or ""
|
|
813
|
+
candidate_name = PurePosixPath(original_name).name
|
|
814
|
+
|
|
815
|
+
if box.transport == "local":
|
|
816
|
+
try:
|
|
817
|
+
target_path = _compose_local_child_path(directory_path, candidate_name)
|
|
818
|
+
except ValueError as exc:
|
|
819
|
+
await file.close()
|
|
820
|
+
return await _render_directory_listing(
|
|
821
|
+
request,
|
|
822
|
+
box,
|
|
823
|
+
directory_path,
|
|
824
|
+
target,
|
|
825
|
+
application_config,
|
|
826
|
+
error_override=f"Invalid filename: {exc}",
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
error_message = None
|
|
830
|
+
success_message: str | None = None
|
|
831
|
+
try:
|
|
832
|
+
contents = await file.read()
|
|
833
|
+
finally:
|
|
834
|
+
await file.close()
|
|
835
|
+
|
|
836
|
+
if not candidate_name:
|
|
837
|
+
error_message = "Select a file to upload"
|
|
838
|
+
elif len(contents) > settings.max_upload_bytes:
|
|
839
|
+
limit_kb = settings.max_upload_bytes // 1024
|
|
840
|
+
error_message = f"Upload exceeds {limit_kb} KB limit"
|
|
841
|
+
|
|
842
|
+
if error_message is None:
|
|
843
|
+
try:
|
|
844
|
+
if Path(target_path).exists():
|
|
845
|
+
error_message = f"File already exists: {Path(target_path).name}"
|
|
846
|
+
else:
|
|
847
|
+
await _local_write_bytes(target_path, contents)
|
|
848
|
+
success_message = f"Uploaded {Path(target_path).name}"
|
|
849
|
+
except Exception as exc:
|
|
850
|
+
error_message = f"Failed to upload file: {exc}"
|
|
851
|
+
|
|
852
|
+
response = await _render_directory_listing(
|
|
853
|
+
request,
|
|
854
|
+
box,
|
|
855
|
+
directory_path,
|
|
856
|
+
target,
|
|
857
|
+
application_config,
|
|
858
|
+
error_override=error_message,
|
|
859
|
+
)
|
|
860
|
+
message = error_message or success_message
|
|
861
|
+
if message:
|
|
862
|
+
trigger_payload = json.dumps(
|
|
863
|
+
{
|
|
864
|
+
"dir-action": {
|
|
865
|
+
"status": "error" if error_message else "success",
|
|
866
|
+
"message": message,
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
)
|
|
870
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
871
|
+
return response
|
|
872
|
+
|
|
873
|
+
try:
|
|
874
|
+
remote_path = _compose_remote_child_path(directory_path, candidate_name)
|
|
875
|
+
except ValueError as exc:
|
|
876
|
+
await file.close()
|
|
877
|
+
return await _render_directory_listing(
|
|
878
|
+
request,
|
|
879
|
+
box,
|
|
880
|
+
directory_path,
|
|
881
|
+
target,
|
|
882
|
+
application_config,
|
|
883
|
+
error_override=f"Invalid filename: {exc}",
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
error_message = None
|
|
887
|
+
success_message: str | None = None
|
|
888
|
+
try:
|
|
889
|
+
contents = await file.read()
|
|
890
|
+
finally:
|
|
891
|
+
await file.close()
|
|
892
|
+
|
|
893
|
+
if not candidate_name:
|
|
894
|
+
error_message = "Select a file to upload"
|
|
895
|
+
elif len(contents) > settings.max_upload_bytes:
|
|
896
|
+
limit_kb = settings.max_upload_bytes // 1024
|
|
897
|
+
error_message = f"Upload exceeds {limit_kb} KB limit"
|
|
898
|
+
|
|
899
|
+
connection = None
|
|
900
|
+
sftp_client = None
|
|
901
|
+
if error_message is None:
|
|
902
|
+
try:
|
|
903
|
+
connection = await connect(
|
|
904
|
+
box.connect_host,
|
|
905
|
+
box.user,
|
|
906
|
+
box.port,
|
|
907
|
+
box.keyfile,
|
|
908
|
+
box.known_hosts,
|
|
909
|
+
application_config.ssh_config_path,
|
|
910
|
+
box.ssh_alias,
|
|
911
|
+
allow_alias=settings.allow_ssh_alias,
|
|
912
|
+
)
|
|
913
|
+
try:
|
|
914
|
+
sftp_client = await connection.start_sftp_client()
|
|
915
|
+
except Exception as exc: # pragma: no cover - depends on remote server
|
|
916
|
+
error_message = f"SFTP session failed: {exc}"
|
|
917
|
+
else:
|
|
918
|
+
try:
|
|
919
|
+
try:
|
|
920
|
+
await sftp_client.stat(remote_path)
|
|
921
|
+
except Exception:
|
|
922
|
+
pass
|
|
923
|
+
else:
|
|
924
|
+
error_message = (
|
|
925
|
+
f"File already exists: {PurePosixPath(remote_path).name}"
|
|
926
|
+
)
|
|
927
|
+
if error_message is None:
|
|
928
|
+
async with await sftp_client.open(remote_path, "wb") as remote_file:
|
|
929
|
+
await remote_file.write(contents)
|
|
930
|
+
success_message = f"Uploaded {PurePosixPath(remote_path).name}"
|
|
931
|
+
except Exception as exc: # pragma: no cover - remote SFTP failures vary
|
|
932
|
+
error_message = f"Failed to upload file: {exc}"
|
|
933
|
+
except SSHError as exc:
|
|
934
|
+
error_message = f"SSH connection failed: {exc}"
|
|
935
|
+
finally:
|
|
936
|
+
if sftp_client is not None:
|
|
937
|
+
try:
|
|
938
|
+
await sftp_client.exit()
|
|
939
|
+
except Exception:
|
|
940
|
+
pass
|
|
941
|
+
if connection is not None:
|
|
942
|
+
connection.close()
|
|
943
|
+
|
|
944
|
+
response = await _render_directory_listing(
|
|
945
|
+
request,
|
|
946
|
+
box,
|
|
947
|
+
directory_path,
|
|
948
|
+
target,
|
|
949
|
+
application_config,
|
|
950
|
+
error_override=error_message,
|
|
951
|
+
)
|
|
952
|
+
message = error_message or success_message
|
|
953
|
+
if message:
|
|
954
|
+
trigger_payload = json.dumps(
|
|
955
|
+
{
|
|
956
|
+
"dir-action": {
|
|
957
|
+
"status": "error" if error_message else "success",
|
|
958
|
+
"message": message,
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
)
|
|
962
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
963
|
+
return response
|
|
964
|
+
|
|
965
|
+
@application.post("/box/{name}/delete", response_class=HTMLResponse)
|
|
966
|
+
async def delete_file(
|
|
967
|
+
name: str,
|
|
968
|
+
request: Request,
|
|
969
|
+
file_path: str = Form(..., alias="path"),
|
|
970
|
+
directory: str = Form(...),
|
|
971
|
+
target: str = Form("browser"),
|
|
972
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
973
|
+
) -> HTMLResponse:
|
|
974
|
+
"""Delete a file from the box."""
|
|
975
|
+
box = find_box(application_config, name)
|
|
976
|
+
if not box:
|
|
977
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
978
|
+
|
|
979
|
+
_require_token(request)
|
|
980
|
+
|
|
981
|
+
directory_path = (
|
|
982
|
+
_normalize_local_path(directory)
|
|
983
|
+
if box.transport == "local"
|
|
984
|
+
else _normalize_directory_path(directory)
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
if box.transport == "local":
|
|
988
|
+
normalized_path = _normalize_local_path(file_path)
|
|
989
|
+
error_message = None
|
|
990
|
+
success_message: str | None = None
|
|
991
|
+
|
|
992
|
+
try:
|
|
993
|
+
await _local_delete_file(normalized_path)
|
|
994
|
+
success_message = f"Deleted {Path(normalized_path).name}"
|
|
995
|
+
except FileNotFoundError:
|
|
996
|
+
error_message = f"File not found: {Path(normalized_path).name}"
|
|
997
|
+
except Exception as exc:
|
|
998
|
+
error_message = f"Failed to delete file: {exc}"
|
|
999
|
+
|
|
1000
|
+
response = await _render_directory_listing(
|
|
1001
|
+
request,
|
|
1002
|
+
box,
|
|
1003
|
+
directory_path,
|
|
1004
|
+
target,
|
|
1005
|
+
application_config,
|
|
1006
|
+
error_override=error_message,
|
|
1007
|
+
)
|
|
1008
|
+
message = error_message or success_message
|
|
1009
|
+
if message:
|
|
1010
|
+
trigger_payload = json.dumps(
|
|
1011
|
+
{
|
|
1012
|
+
"dir-action": {
|
|
1013
|
+
"status": "error" if error_message else "success",
|
|
1014
|
+
"message": message,
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
)
|
|
1018
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
1019
|
+
return response
|
|
1020
|
+
|
|
1021
|
+
# Remote file deletion
|
|
1022
|
+
error_message = None
|
|
1023
|
+
success_message: str | None = None
|
|
1024
|
+
connection = None
|
|
1025
|
+
sftp_client = None
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
connection = await connect(
|
|
1029
|
+
box.connect_host,
|
|
1030
|
+
box.user,
|
|
1031
|
+
box.port,
|
|
1032
|
+
box.keyfile,
|
|
1033
|
+
box.known_hosts,
|
|
1034
|
+
application_config.ssh_config_path,
|
|
1035
|
+
box.ssh_alias,
|
|
1036
|
+
allow_alias=settings.allow_ssh_alias,
|
|
1037
|
+
)
|
|
1038
|
+
try:
|
|
1039
|
+
sftp_client = await connection.start_sftp_client()
|
|
1040
|
+
except Exception as exc:
|
|
1041
|
+
error_message = f"SFTP session failed: {exc}"
|
|
1042
|
+
else:
|
|
1043
|
+
try:
|
|
1044
|
+
await sftp_client.remove(file_path)
|
|
1045
|
+
success_message = f"Deleted {PurePosixPath(file_path).name}"
|
|
1046
|
+
except FileNotFoundError:
|
|
1047
|
+
error_message = f"File not found: {PurePosixPath(file_path).name}"
|
|
1048
|
+
except Exception as exc:
|
|
1049
|
+
error_message = f"Failed to delete file: {exc}"
|
|
1050
|
+
except SSHError as exc:
|
|
1051
|
+
error_message = f"SSH connection failed: {exc}"
|
|
1052
|
+
finally:
|
|
1053
|
+
if sftp_client is not None:
|
|
1054
|
+
try:
|
|
1055
|
+
await sftp_client.exit()
|
|
1056
|
+
except Exception:
|
|
1057
|
+
pass
|
|
1058
|
+
if connection is not None:
|
|
1059
|
+
connection.close()
|
|
1060
|
+
|
|
1061
|
+
response = await _render_directory_listing(
|
|
1062
|
+
request,
|
|
1063
|
+
box,
|
|
1064
|
+
directory_path,
|
|
1065
|
+
target,
|
|
1066
|
+
application_config,
|
|
1067
|
+
error_override=error_message,
|
|
1068
|
+
)
|
|
1069
|
+
message = error_message or success_message
|
|
1070
|
+
if message:
|
|
1071
|
+
trigger_payload = json.dumps(
|
|
1072
|
+
{
|
|
1073
|
+
"dir-action": {
|
|
1074
|
+
"status": "error" if error_message else "success",
|
|
1075
|
+
"message": message,
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
)
|
|
1079
|
+
response.headers["HX-Trigger"] = trigger_payload
|
|
1080
|
+
return response
|
|
1081
|
+
|
|
1082
|
+
@application.get("/box/{name}/cat", response_class=HTMLResponse)
|
|
1083
|
+
async def view_file(
|
|
1084
|
+
name: str,
|
|
1085
|
+
request: Request,
|
|
1086
|
+
file_path: str = Query(..., alias="path"),
|
|
1087
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1088
|
+
) -> HTMLResponse:
|
|
1089
|
+
"""Render a read-only preview of a remote or local file.
|
|
1090
|
+
|
|
1091
|
+
English:
|
|
1092
|
+
Loads text content (or inline images) and renders the preview page.
|
|
1093
|
+
|
|
1094
|
+
日本語:
|
|
1095
|
+
テキストまたは画像を読み込み、プレビュー用ページを表示します。
|
|
1096
|
+
"""
|
|
1097
|
+
|
|
1098
|
+
box = find_box(application_config, name)
|
|
1099
|
+
if not box:
|
|
1100
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
1101
|
+
|
|
1102
|
+
if box.transport == "local":
|
|
1103
|
+
normalized_path = _normalize_local_path(file_path)
|
|
1104
|
+
suffix = Path(normalized_path).suffix.lower()
|
|
1105
|
+
image_mime = IMAGE_CONTENT_TYPES.get(suffix)
|
|
1106
|
+
image_data: str | None = None
|
|
1107
|
+
image_too_large = False
|
|
1108
|
+
if image_mime:
|
|
1109
|
+
try:
|
|
1110
|
+
image_bytes, too_large = await _local_read_bytes(
|
|
1111
|
+
normalized_path, MAX_IMAGE_PREVIEW_BYTES
|
|
1112
|
+
)
|
|
1113
|
+
except Exception as exc:
|
|
1114
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1115
|
+
if too_large:
|
|
1116
|
+
image_too_large = True
|
|
1117
|
+
else:
|
|
1118
|
+
image_data = base64.b64encode(image_bytes).decode("ascii")
|
|
1119
|
+
|
|
1120
|
+
text_content: str | None = None
|
|
1121
|
+
if not image_mime or image_too_large:
|
|
1122
|
+
try:
|
|
1123
|
+
text_content = await _local_read_text(
|
|
1124
|
+
normalized_path, settings.max_upload_bytes
|
|
1125
|
+
)
|
|
1126
|
+
except Exception as exc:
|
|
1127
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1128
|
+
|
|
1129
|
+
# Get the directory containing the file
|
|
1130
|
+
parent_dir = str(Path(normalized_path).parent)
|
|
1131
|
+
|
|
1132
|
+
context = {
|
|
1133
|
+
"box": box,
|
|
1134
|
+
"path": normalized_path,
|
|
1135
|
+
"parent_directory": parent_dir,
|
|
1136
|
+
"content": text_content or "",
|
|
1137
|
+
"syntax_class": _syntax_from_filename(normalized_path),
|
|
1138
|
+
"app_version": app_version,
|
|
1139
|
+
"image_data": image_data,
|
|
1140
|
+
"image_mime": image_mime,
|
|
1141
|
+
"image_too_large": image_too_large,
|
|
1142
|
+
"image_limit_kb": MAX_IMAGE_PREVIEW_BYTES // 1024,
|
|
1143
|
+
}
|
|
1144
|
+
return templates.TemplateResponse(request, "file_view.html", context)
|
|
1145
|
+
|
|
1146
|
+
connection = None
|
|
1147
|
+
try:
|
|
1148
|
+
connection = await connect(
|
|
1149
|
+
box.connect_host,
|
|
1150
|
+
box.user,
|
|
1151
|
+
box.port,
|
|
1152
|
+
box.keyfile,
|
|
1153
|
+
box.known_hosts,
|
|
1154
|
+
application_config.ssh_config_path,
|
|
1155
|
+
box.ssh_alias,
|
|
1156
|
+
allow_alias=settings.allow_ssh_alias,
|
|
1157
|
+
)
|
|
1158
|
+
except SSHError as exc:
|
|
1159
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1160
|
+
|
|
1161
|
+
try:
|
|
1162
|
+
suffix = Path(file_path).suffix.lower()
|
|
1163
|
+
image_mime = IMAGE_CONTENT_TYPES.get(suffix)
|
|
1164
|
+
image_data: str | None = None
|
|
1165
|
+
image_too_large = False
|
|
1166
|
+
if image_mime:
|
|
1167
|
+
try:
|
|
1168
|
+
image_bytes, too_large = await _read_file_bytes(
|
|
1169
|
+
connection, file_path, MAX_IMAGE_PREVIEW_BYTES
|
|
1170
|
+
)
|
|
1171
|
+
except Exception as exc:
|
|
1172
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1173
|
+
if too_large:
|
|
1174
|
+
image_too_large = True
|
|
1175
|
+
else:
|
|
1176
|
+
image_data = base64.b64encode(image_bytes).decode("ascii")
|
|
1177
|
+
|
|
1178
|
+
text_content: str | None = None
|
|
1179
|
+
if not image_mime or image_too_large:
|
|
1180
|
+
try:
|
|
1181
|
+
text_content = await _read_remote_text(
|
|
1182
|
+
connection, file_path, settings.max_upload_bytes
|
|
1183
|
+
)
|
|
1184
|
+
except Exception as exc:
|
|
1185
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1186
|
+
|
|
1187
|
+
# Get the directory containing the file
|
|
1188
|
+
parent_dir = str(PurePosixPath(file_path).parent)
|
|
1189
|
+
|
|
1190
|
+
context = {
|
|
1191
|
+
"box": box,
|
|
1192
|
+
"path": file_path,
|
|
1193
|
+
"parent_directory": parent_dir,
|
|
1194
|
+
"content": text_content or "",
|
|
1195
|
+
"syntax_class": _syntax_from_filename(file_path),
|
|
1196
|
+
"app_version": app_version,
|
|
1197
|
+
"image_data": image_data,
|
|
1198
|
+
"image_mime": image_mime,
|
|
1199
|
+
"image_too_large": image_too_large,
|
|
1200
|
+
"image_limit_kb": MAX_IMAGE_PREVIEW_BYTES // 1024,
|
|
1201
|
+
}
|
|
1202
|
+
return templates.TemplateResponse(request, "file_view.html", context)
|
|
1203
|
+
finally:
|
|
1204
|
+
if connection is not None:
|
|
1205
|
+
connection.close()
|
|
1206
|
+
|
|
1207
|
+
@application.api_route(
|
|
1208
|
+
"/box/{name}/edit",
|
|
1209
|
+
methods=["GET", "POST"],
|
|
1210
|
+
response_class=HTMLResponse,
|
|
1211
|
+
response_model=None,
|
|
1212
|
+
)
|
|
1213
|
+
async def edit_file(
|
|
1214
|
+
name: str,
|
|
1215
|
+
request: Request,
|
|
1216
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1217
|
+
) -> HTMLResponse | RedirectResponse:
|
|
1218
|
+
file_path = request.query_params.get("path")
|
|
1219
|
+
if not file_path:
|
|
1220
|
+
raise HTTPException(status_code=400, detail="path required")
|
|
1221
|
+
|
|
1222
|
+
box = find_box(application_config, name)
|
|
1223
|
+
if not box:
|
|
1224
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
1225
|
+
|
|
1226
|
+
if box.transport == "local":
|
|
1227
|
+
normalized_path = _normalize_local_path(file_path)
|
|
1228
|
+
|
|
1229
|
+
if request.method == "GET":
|
|
1230
|
+
try:
|
|
1231
|
+
content = await _local_read_text(normalized_path, 262144)
|
|
1232
|
+
except Exception as exc:
|
|
1233
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1234
|
+
context = {
|
|
1235
|
+
"box": box,
|
|
1236
|
+
"path": normalized_path,
|
|
1237
|
+
"content": content,
|
|
1238
|
+
"app_version": app_version,
|
|
1239
|
+
}
|
|
1240
|
+
return templates.TemplateResponse(request, "file_edit.html", context)
|
|
1241
|
+
|
|
1242
|
+
_require_token(request)
|
|
1243
|
+
payload = await request.json()
|
|
1244
|
+
content = payload.get("content")
|
|
1245
|
+
if content is None:
|
|
1246
|
+
raise HTTPException(status_code=400, detail="Missing content")
|
|
1247
|
+
if len(content.encode("utf-8")) > 262144:
|
|
1248
|
+
raise HTTPException(status_code=400, detail="File exceeds 256KB editing limit")
|
|
1249
|
+
|
|
1250
|
+
try:
|
|
1251
|
+
await _local_write_text(normalized_path, content)
|
|
1252
|
+
except Exception as exc:
|
|
1253
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1254
|
+
|
|
1255
|
+
context = {
|
|
1256
|
+
"box": box,
|
|
1257
|
+
"path": normalized_path,
|
|
1258
|
+
"content": content,
|
|
1259
|
+
"app_version": app_version,
|
|
1260
|
+
}
|
|
1261
|
+
return templates.TemplateResponse(request, "file_edit.html", context)
|
|
1262
|
+
|
|
1263
|
+
connection = await connect(
|
|
1264
|
+
box.connect_host,
|
|
1265
|
+
box.user,
|
|
1266
|
+
box.port,
|
|
1267
|
+
box.keyfile,
|
|
1268
|
+
box.known_hosts,
|
|
1269
|
+
application_config.ssh_config_path,
|
|
1270
|
+
box.ssh_alias,
|
|
1271
|
+
allow_alias=settings.allow_ssh_alias,
|
|
1272
|
+
)
|
|
1273
|
+
|
|
1274
|
+
try:
|
|
1275
|
+
if request.method == "GET":
|
|
1276
|
+
try:
|
|
1277
|
+
content = await _read_remote_text(connection, file_path, 262144)
|
|
1278
|
+
except Exception as exc:
|
|
1279
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
1280
|
+
context = {
|
|
1281
|
+
"box": box,
|
|
1282
|
+
"path": file_path,
|
|
1283
|
+
"content": content,
|
|
1284
|
+
"app_version": app_version,
|
|
1285
|
+
}
|
|
1286
|
+
return templates.TemplateResponse(request, "file_edit.html", context)
|
|
1287
|
+
|
|
1288
|
+
_require_token(request)
|
|
1289
|
+
payload = await request.json()
|
|
1290
|
+
content = payload.get("content")
|
|
1291
|
+
if content is None:
|
|
1292
|
+
raise HTTPException(status_code=400, detail="Missing content")
|
|
1293
|
+
if len(content.encode("utf-8")) > 262144:
|
|
1294
|
+
raise HTTPException(status_code=400, detail="File exceeds 256KB editing limit")
|
|
1295
|
+
|
|
1296
|
+
sftp_client = await connection.start_sftp_client()
|
|
1297
|
+
try:
|
|
1298
|
+
async with await sftp_client.open(file_path, "w", encoding="utf-8") as remote_file:
|
|
1299
|
+
await remote_file.write(content)
|
|
1300
|
+
finally:
|
|
1301
|
+
try:
|
|
1302
|
+
await sftp_client.exit()
|
|
1303
|
+
except Exception:
|
|
1304
|
+
pass
|
|
1305
|
+
return templates.TemplateResponse(
|
|
1306
|
+
request,
|
|
1307
|
+
"file_edit.html",
|
|
1308
|
+
{
|
|
1309
|
+
"box": box,
|
|
1310
|
+
"path": file_path,
|
|
1311
|
+
"content": content,
|
|
1312
|
+
"app_version": app_version,
|
|
1313
|
+
},
|
|
1314
|
+
)
|
|
1315
|
+
finally:
|
|
1316
|
+
connection.close()
|
|
1317
|
+
|
|
1318
|
+
@application.post("/box/{name}/fav", response_class=PlainTextResponse)
|
|
1319
|
+
async def toggle_favorite(
|
|
1320
|
+
name: str,
|
|
1321
|
+
request: Request,
|
|
1322
|
+
directory_path: str = Query(..., alias="path"),
|
|
1323
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1324
|
+
) -> str:
|
|
1325
|
+
"""Toggle a favorite directory for a box.
|
|
1326
|
+
|
|
1327
|
+
English:
|
|
1328
|
+
Adds or removes ``directory_path`` from the stored favourites for
|
|
1329
|
+
the given box.
|
|
1330
|
+
|
|
1331
|
+
日本語:
|
|
1332
|
+
指定されたディレクトリをお気に入りに追加または削除します。
|
|
1333
|
+
|
|
1334
|
+
Args:
|
|
1335
|
+
name: Box identifier from the URL.
|
|
1336
|
+
directory_path: Remote directory to toggle as favorite.
|
|
1337
|
+
application_config: Configuration loaded from disk.
|
|
1338
|
+
|
|
1339
|
+
Returns:
|
|
1340
|
+
str: Literal ``"ok"`` acknowledging persistence.
|
|
1341
|
+
|
|
1342
|
+
Raises:
|
|
1343
|
+
HTTPException: When the requested box does not exist.
|
|
1344
|
+
"""
|
|
1345
|
+
|
|
1346
|
+
_require_token(request)
|
|
1347
|
+
|
|
1348
|
+
box = find_box(application_config, name)
|
|
1349
|
+
if not box:
|
|
1350
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
1351
|
+
|
|
1352
|
+
await state.toggle_favorite_async(name, directory_path)
|
|
1353
|
+
box.favorites = await state.list_favorites_async(name)
|
|
1354
|
+
return "ok"
|
|
1355
|
+
|
|
1356
|
+
@application.get("/boxes/new", response_class=HTMLResponse)
|
|
1357
|
+
async def new_box_form(
|
|
1358
|
+
request: Request,
|
|
1359
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1360
|
+
) -> HTMLResponse:
|
|
1361
|
+
"""Render the form to add a custom box.
|
|
1362
|
+
|
|
1363
|
+
English:
|
|
1364
|
+
Presents the HTML form used to create additional stored boxes.
|
|
1365
|
+
|
|
1366
|
+
日本語:
|
|
1367
|
+
追加のボックスを作成するためのフォームを表示します。
|
|
1368
|
+
"""
|
|
1369
|
+
|
|
1370
|
+
context = {
|
|
1371
|
+
"configuration_path": str(get_config_path()),
|
|
1372
|
+
"existing_names": [box.name for box in application_config.boxes],
|
|
1373
|
+
"app_version": app_version,
|
|
1374
|
+
}
|
|
1375
|
+
return templates.TemplateResponse(request, "new_box.html", context)
|
|
1376
|
+
|
|
1377
|
+
@application.post("/boxes/new")
|
|
1378
|
+
async def create_box(
|
|
1379
|
+
request: Request,
|
|
1380
|
+
name: str = Form(...),
|
|
1381
|
+
host: str = Form(""),
|
|
1382
|
+
user: str = Form(""),
|
|
1383
|
+
port: int = Form(22),
|
|
1384
|
+
keyfile: str = Form(""),
|
|
1385
|
+
ssh_alias: str = Form(""),
|
|
1386
|
+
default_dir: str = Form(""),
|
|
1387
|
+
favorites: str = Form(""),
|
|
1388
|
+
known_hosts: str = Form(""),
|
|
1389
|
+
agent: bool = Form(False),
|
|
1390
|
+
) -> RedirectResponse:
|
|
1391
|
+
"""Persist a new custom box definition supplied by the user."""
|
|
1392
|
+
|
|
1393
|
+
_require_token(request)
|
|
1394
|
+
|
|
1395
|
+
cleaned_name = name.strip()
|
|
1396
|
+
if not cleaned_name:
|
|
1397
|
+
raise HTTPException(status_code=400, detail="Box name is required")
|
|
1398
|
+
|
|
1399
|
+
favorites_list = [line.strip() for line in favorites.splitlines() if line.strip()]
|
|
1400
|
+
|
|
1401
|
+
new_box = StoredBox(
|
|
1402
|
+
name=cleaned_name,
|
|
1403
|
+
host=host.strip() or None,
|
|
1404
|
+
user=user.strip() or None,
|
|
1405
|
+
port=port or None,
|
|
1406
|
+
keyfile=keyfile.strip() or None,
|
|
1407
|
+
agent=agent,
|
|
1408
|
+
favorites=[],
|
|
1409
|
+
default_dir=default_dir.strip() or None,
|
|
1410
|
+
known_hosts=known_hosts.strip() or None,
|
|
1411
|
+
ssh_alias=ssh_alias.strip() or None,
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
application_config = load_config()
|
|
1415
|
+
application_config.stored[new_box.name] = new_box
|
|
1416
|
+
rebuild_boxes(application_config)
|
|
1417
|
+
save_config(application_config)
|
|
1418
|
+
await state.replace_favorites_async(new_box.name, favorites_list)
|
|
1419
|
+
return RedirectResponse(url="/boxes", status_code=303)
|
|
1420
|
+
|
|
1421
|
+
@application.post("/box/{name}/refresh", response_class=PlainTextResponse)
|
|
1422
|
+
async def refresh_box(
|
|
1423
|
+
name: str,
|
|
1424
|
+
request: Request,
|
|
1425
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1426
|
+
) -> str:
|
|
1427
|
+
"""Remove connection overrides so SSH config values apply.
|
|
1428
|
+
|
|
1429
|
+
English:
|
|
1430
|
+
Clears stored overrides for the chosen box and rebuilds the merged
|
|
1431
|
+
configuration.
|
|
1432
|
+
|
|
1433
|
+
日本語:
|
|
1434
|
+
対象ボックスの上書き設定を削除し、統合された設定を再構築します。
|
|
1435
|
+
"""
|
|
1436
|
+
|
|
1437
|
+
_require_token(request)
|
|
1438
|
+
|
|
1439
|
+
await state.replace_favorites_async(name, [])
|
|
1440
|
+
|
|
1441
|
+
stored_override = application_config.stored.get(name)
|
|
1442
|
+
if stored_override is not None:
|
|
1443
|
+
stored_override.host = None
|
|
1444
|
+
stored_override.user = None
|
|
1445
|
+
stored_override.port = None
|
|
1446
|
+
stored_override.keyfile = None
|
|
1447
|
+
stored_override.known_hosts = None
|
|
1448
|
+
stored_override.ssh_alias = None
|
|
1449
|
+
|
|
1450
|
+
if not any(
|
|
1451
|
+
[
|
|
1452
|
+
stored_override.host,
|
|
1453
|
+
stored_override.user,
|
|
1454
|
+
stored_override.port,
|
|
1455
|
+
stored_override.keyfile,
|
|
1456
|
+
stored_override.known_hosts,
|
|
1457
|
+
stored_override.ssh_alias,
|
|
1458
|
+
stored_override.default_dir,
|
|
1459
|
+
stored_override.agent,
|
|
1460
|
+
]
|
|
1461
|
+
):
|
|
1462
|
+
application_config.stored.pop(name, None)
|
|
1463
|
+
|
|
1464
|
+
save_config(application_config)
|
|
1465
|
+
|
|
1466
|
+
rebuild_boxes(application_config)
|
|
1467
|
+
return "ok"
|
|
1468
|
+
|
|
1469
|
+
@application.get("/term", response_class=HTMLResponse)
|
|
1470
|
+
async def term_page(
|
|
1471
|
+
request: Request,
|
|
1472
|
+
host: str,
|
|
1473
|
+
session: str | None = None,
|
|
1474
|
+
columns: int = 120,
|
|
1475
|
+
rows: int = 32,
|
|
1476
|
+
directory: str = Query(..., alias="dir"),
|
|
1477
|
+
application_config: AppConfig = Depends(_get_application_config),
|
|
1478
|
+
) -> HTMLResponse:
|
|
1479
|
+
"""Render the terminal page for tmux access.
|
|
1480
|
+
|
|
1481
|
+
English:
|
|
1482
|
+
Builds the xterm.js front-end for either SSH or local tmux sessions.
|
|
1483
|
+
|
|
1484
|
+
日本語:
|
|
1485
|
+
SSH またはローカル tmux セッションに接続するための xterm.js ベースの
|
|
1486
|
+
端末ページを生成します。
|
|
1487
|
+
|
|
1488
|
+
Args:
|
|
1489
|
+
request: Incoming HTTP request.
|
|
1490
|
+
host: Box identifier provided by the query parameter.
|
|
1491
|
+
session: Optional tmux session name override.
|
|
1492
|
+
columns: Initial terminal width.
|
|
1493
|
+
rows: Initial terminal height.
|
|
1494
|
+
directory: Preferred directory for the tmux session.
|
|
1495
|
+
application_config: Configuration loaded from disk.
|
|
1496
|
+
|
|
1497
|
+
Returns:
|
|
1498
|
+
HTMLResponse: Rendered terminal page.
|
|
1499
|
+
|
|
1500
|
+
Raises:
|
|
1501
|
+
HTTPException: When the requested box does not exist.
|
|
1502
|
+
"""
|
|
1503
|
+
|
|
1504
|
+
box = find_box(application_config, host)
|
|
1505
|
+
if not box:
|
|
1506
|
+
raise HTTPException(status_code=404, detail="Unknown box")
|
|
1507
|
+
display_directory = (
|
|
1508
|
+
_normalize_local_path(directory)
|
|
1509
|
+
if getattr(box, "transport", "ssh") == "local"
|
|
1510
|
+
else directory
|
|
1511
|
+
)
|
|
1512
|
+
if not session:
|
|
1513
|
+
base = Path(display_directory).name or "sshler"
|
|
1514
|
+
session = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in base)
|
|
1515
|
+
context = {
|
|
1516
|
+
"box": box,
|
|
1517
|
+
"directory": display_directory,
|
|
1518
|
+
"session": session,
|
|
1519
|
+
"cols": columns,
|
|
1520
|
+
"rows": rows,
|
|
1521
|
+
"app_version": app_version,
|
|
1522
|
+
}
|
|
1523
|
+
return templates.TemplateResponse(request, "term.html", context)
|
|
1524
|
+
|
|
1525
|
+
@application.websocket("/ws/term")
|
|
1526
|
+
async def terminal_socket(
|
|
1527
|
+
websocket: WebSocket,
|
|
1528
|
+
host: str = Query(...),
|
|
1529
|
+
directory: str = Query(..., alias="dir"),
|
|
1530
|
+
session: str = Query("sshler"),
|
|
1531
|
+
columns: int = Query(120, alias="cols"),
|
|
1532
|
+
rows: int = Query(32, alias="rows"),
|
|
1533
|
+
) -> None:
|
|
1534
|
+
"""Bridge between the browser websocket and tmux over SSH or locally.
|
|
1535
|
+
|
|
1536
|
+
English:
|
|
1537
|
+
Streams bytes between the browser and tmux, handling command
|
|
1538
|
+
messages (resize, rename, etc.) and window polling.
|
|
1539
|
+
|
|
1540
|
+
日本語:
|
|
1541
|
+
ブラウザと tmux の間でバイトストリームを仲介し、リサイズやウィンドウ
|
|
1542
|
+
切り替えなどのコマンドメッセージを処理します。
|
|
1543
|
+
|
|
1544
|
+
Args:
|
|
1545
|
+
websocket: Accepted websocket connection from the browser.
|
|
1546
|
+
host: Box identifier provided by the client.
|
|
1547
|
+
directory: Requested working directory.
|
|
1548
|
+
session: Requested tmux session name.
|
|
1549
|
+
columns: Terminal width reported by the client.
|
|
1550
|
+
rows: Terminal height reported by the client.
|
|
1551
|
+
|
|
1552
|
+
Returns:
|
|
1553
|
+
None: The coroutine completes when the websocket terminates.
|
|
1554
|
+
"""
|
|
1555
|
+
|
|
1556
|
+
settings: ServerSettings = websocket.app.state.settings # type: ignore[attr-defined]
|
|
1557
|
+
|
|
1558
|
+
if settings.basic_auth_header:
|
|
1559
|
+
auth_header = websocket.headers.get("authorization")
|
|
1560
|
+
if auth_header != settings.basic_auth_header:
|
|
1561
|
+
await websocket.close(code=4401, reason="Unauthorized")
|
|
1562
|
+
return
|
|
1563
|
+
|
|
1564
|
+
token_param = websocket.query_params.get("token")
|
|
1565
|
+
if settings.csrf_token and token_param != settings.csrf_token:
|
|
1566
|
+
await websocket.close(code=4403, reason="Invalid token")
|
|
1567
|
+
return
|
|
1568
|
+
|
|
1569
|
+
await websocket.accept()
|
|
1570
|
+
application_config = load_config()
|
|
1571
|
+
box = find_box(application_config, host)
|
|
1572
|
+
if not box:
|
|
1573
|
+
await websocket.close()
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
transport = getattr(box, "transport", "ssh")
|
|
1577
|
+
normalized_directory = (
|
|
1578
|
+
_normalize_local_path(directory)
|
|
1579
|
+
if transport == "local"
|
|
1580
|
+
else _normalize_directory_path(directory)
|
|
1581
|
+
)
|
|
1582
|
+
|
|
1583
|
+
# Set up logging
|
|
1584
|
+
import logging
|
|
1585
|
+
logger = logging.getLogger("sshler.webapp")
|
|
1586
|
+
|
|
1587
|
+
# Configure file logging if not already configured
|
|
1588
|
+
if not logger.handlers:
|
|
1589
|
+
logger.setLevel(logging.DEBUG)
|
|
1590
|
+
file_handler = logging.FileHandler("debug.log")
|
|
1591
|
+
file_handler.setLevel(logging.DEBUG)
|
|
1592
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
1593
|
+
file_handler.setFormatter(formatter)
|
|
1594
|
+
logger.addHandler(file_handler)
|
|
1595
|
+
|
|
1596
|
+
connection: asyncssh.SSHClientConnection | None = None
|
|
1597
|
+
process = None
|
|
1598
|
+
|
|
1599
|
+
try:
|
|
1600
|
+
if transport == "local":
|
|
1601
|
+
try:
|
|
1602
|
+
is_directory = await _local_is_directory(normalized_directory)
|
|
1603
|
+
except Exception:
|
|
1604
|
+
is_directory = False
|
|
1605
|
+
if not is_directory:
|
|
1606
|
+
normalized_directory = _normalize_local_path(box.default_dir)
|
|
1607
|
+
|
|
1608
|
+
# Debug: Log the command we're about to run
|
|
1609
|
+
logger.info(f"Starting local tmux: transport={transport}, dir={normalized_directory}, session={session}")
|
|
1610
|
+
|
|
1611
|
+
try:
|
|
1612
|
+
process = await _open_local_tmux(normalized_directory, session)
|
|
1613
|
+
logger.info(f"Local tmux process started: {process}")
|
|
1614
|
+
except Exception as exc:
|
|
1615
|
+
logger.error(f"Failed to start local tmux: {exc}", exc_info=True)
|
|
1616
|
+
error_msg = f"Connection failed: {exc}\r\n"
|
|
1617
|
+
try:
|
|
1618
|
+
await websocket.send_text(error_msg)
|
|
1619
|
+
except Exception:
|
|
1620
|
+
pass
|
|
1621
|
+
await websocket.close()
|
|
1622
|
+
return
|
|
1623
|
+
else:
|
|
1624
|
+
try:
|
|
1625
|
+
connection = await connect(
|
|
1626
|
+
box.connect_host,
|
|
1627
|
+
box.user,
|
|
1628
|
+
box.port,
|
|
1629
|
+
box.keyfile,
|
|
1630
|
+
box.known_hosts,
|
|
1631
|
+
application_config.ssh_config_path,
|
|
1632
|
+
box.ssh_alias,
|
|
1633
|
+
)
|
|
1634
|
+
except Exception as exc: # pragma: no cover
|
|
1635
|
+
# network errors are environment specific
|
|
1636
|
+
await websocket.send_text(f"Connection failed: {exc}")
|
|
1637
|
+
await websocket.close()
|
|
1638
|
+
return
|
|
1639
|
+
|
|
1640
|
+
try:
|
|
1641
|
+
is_directory = await sftp_is_directory(connection, normalized_directory)
|
|
1642
|
+
if not is_directory:
|
|
1643
|
+
normalized_directory = box.default_dir or f"/home/{box.user}"
|
|
1644
|
+
except Exception:
|
|
1645
|
+
pass
|
|
1646
|
+
|
|
1647
|
+
process = await open_tmux(
|
|
1648
|
+
connection,
|
|
1649
|
+
working_directory=normalized_directory,
|
|
1650
|
+
session=session,
|
|
1651
|
+
columns=columns,
|
|
1652
|
+
rows=rows,
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
async def reader() -> None:
|
|
1656
|
+
logger.info("Reader task started")
|
|
1657
|
+
try:
|
|
1658
|
+
while True:
|
|
1659
|
+
logger.debug("Reader: waiting for stdout data...")
|
|
1660
|
+
data = await process.stdout.read(32768)
|
|
1661
|
+
if not data:
|
|
1662
|
+
logger.info("Reader: got empty data, ending")
|
|
1663
|
+
break
|
|
1664
|
+
logger.debug(f"Reader: got {len(data)} bytes, sending to websocket")
|
|
1665
|
+
await websocket.send_bytes(data)
|
|
1666
|
+
except Exception as exc:
|
|
1667
|
+
logger.error(f"Reader exception: {exc}", exc_info=True)
|
|
1668
|
+
|
|
1669
|
+
async def writer() -> None:
|
|
1670
|
+
logger.info("Writer task started")
|
|
1671
|
+
try:
|
|
1672
|
+
while True:
|
|
1673
|
+
logger.debug("Writer: waiting for websocket message...")
|
|
1674
|
+
message = await websocket.receive()
|
|
1675
|
+
message_type = message.get("type")
|
|
1676
|
+
if message_type == "websocket.disconnect":
|
|
1677
|
+
logger.info("Writer: got disconnect")
|
|
1678
|
+
break
|
|
1679
|
+
if "text" in message and message["text"] is not None:
|
|
1680
|
+
logger.debug(f"Writer: got text message: {message['text'][:50]}")
|
|
1681
|
+
await _handle_control_message(
|
|
1682
|
+
message["text"],
|
|
1683
|
+
process,
|
|
1684
|
+
connection,
|
|
1685
|
+
session,
|
|
1686
|
+
transport,
|
|
1687
|
+
)
|
|
1688
|
+
elif "bytes" in message and message["bytes"] is not None:
|
|
1689
|
+
logger.debug(f"Writer: got {len(message['bytes'])} bytes, writing to stdin")
|
|
1690
|
+
process.stdin.write(message["bytes"])
|
|
1691
|
+
except WebSocketDisconnect:
|
|
1692
|
+
logger.info("Writer: websocket disconnected")
|
|
1693
|
+
except Exception as exc:
|
|
1694
|
+
logger.error(f"Writer exception: {exc}", exc_info=True)
|
|
1695
|
+
|
|
1696
|
+
async def poll_tmux_windows() -> None:
|
|
1697
|
+
try:
|
|
1698
|
+
while True:
|
|
1699
|
+
if transport == "local":
|
|
1700
|
+
window_payload = await _list_local_tmux_windows(session)
|
|
1701
|
+
else:
|
|
1702
|
+
window_payload = await _list_tmux_windows(connection, session)
|
|
1703
|
+
if window_payload is not None:
|
|
1704
|
+
await websocket.send_text(
|
|
1705
|
+
json.dumps({"op": "windows", "windows": window_payload})
|
|
1706
|
+
)
|
|
1707
|
+
await asyncio.sleep(2)
|
|
1708
|
+
except Exception:
|
|
1709
|
+
pass
|
|
1710
|
+
|
|
1711
|
+
poller = asyncio.create_task(poll_tmux_windows())
|
|
1712
|
+
try:
|
|
1713
|
+
await asyncio.gather(reader(), writer(), poller)
|
|
1714
|
+
finally:
|
|
1715
|
+
poller.cancel()
|
|
1716
|
+
finally:
|
|
1717
|
+
try:
|
|
1718
|
+
if process:
|
|
1719
|
+
if transport == "local":
|
|
1720
|
+
# Don't terminate! Just close stdin/stdout to detach gracefully
|
|
1721
|
+
# The tmux session will continue running in WSL
|
|
1722
|
+
try:
|
|
1723
|
+
process.stdin.close()
|
|
1724
|
+
except Exception:
|
|
1725
|
+
pass
|
|
1726
|
+
try:
|
|
1727
|
+
# Give it a moment to flush
|
|
1728
|
+
await asyncio.sleep(0.1)
|
|
1729
|
+
except Exception:
|
|
1730
|
+
pass
|
|
1731
|
+
else:
|
|
1732
|
+
process.stdin.write_eof()
|
|
1733
|
+
process.close()
|
|
1734
|
+
except Exception:
|
|
1735
|
+
pass
|
|
1736
|
+
try:
|
|
1737
|
+
if connection is not None:
|
|
1738
|
+
connection.close()
|
|
1739
|
+
except Exception:
|
|
1740
|
+
pass
|
|
1741
|
+
|
|
1742
|
+
return application
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _compute_app_version() -> str:
|
|
1746
|
+
parts = [__version__]
|
|
1747
|
+
try:
|
|
1748
|
+
result = subprocess.run(
|
|
1749
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
1750
|
+
capture_output=True,
|
|
1751
|
+
text=True,
|
|
1752
|
+
check=True,
|
|
1753
|
+
)
|
|
1754
|
+
git_hash = result.stdout.strip()
|
|
1755
|
+
if git_hash:
|
|
1756
|
+
parts.append(f"({git_hash})")
|
|
1757
|
+
except Exception:
|
|
1758
|
+
pass
|
|
1759
|
+
return " ".join(part for part in parts if part)
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def _syntax_from_filename(path: str) -> str:
|
|
1763
|
+
suffix = Path(path).suffix.lower()
|
|
1764
|
+
mapping = {
|
|
1765
|
+
".py": "python",
|
|
1766
|
+
".js": "javascript",
|
|
1767
|
+
".ts": "typescript",
|
|
1768
|
+
".json": "json",
|
|
1769
|
+
".yaml": "yaml",
|
|
1770
|
+
".yml": "yaml",
|
|
1771
|
+
".md": "markdown",
|
|
1772
|
+
".sh": "bash",
|
|
1773
|
+
".bash": "bash",
|
|
1774
|
+
".html": "markup",
|
|
1775
|
+
".css": "css",
|
|
1776
|
+
".toml": "toml",
|
|
1777
|
+
".ini": "ini",
|
|
1778
|
+
}
|
|
1779
|
+
return mapping.get(suffix, "").strip()
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
async def _read_file_bytes(
|
|
1783
|
+
connection: asyncssh.SSHClientConnection, path: str, limit: int
|
|
1784
|
+
) -> tuple[bytes, bool]:
|
|
1785
|
+
sftp_client = await connection.start_sftp_client()
|
|
1786
|
+
try:
|
|
1787
|
+
async with await sftp_client.open(path, "rb") as remote_file:
|
|
1788
|
+
data = await remote_file.read(limit + 1)
|
|
1789
|
+
finally:
|
|
1790
|
+
try:
|
|
1791
|
+
await sftp_client.exit()
|
|
1792
|
+
except Exception:
|
|
1793
|
+
pass
|
|
1794
|
+
too_large = len(data) > limit
|
|
1795
|
+
if too_large:
|
|
1796
|
+
return b"", True
|
|
1797
|
+
return data, False
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
async def _read_remote_text(
|
|
1801
|
+
connection: asyncssh.SSHClientConnection, path: str, limit: int
|
|
1802
|
+
) -> str:
|
|
1803
|
+
"""Retrieve UTF-8 text from an SFTP connection with graceful fallback."""
|
|
1804
|
+
|
|
1805
|
+
try:
|
|
1806
|
+
return await sftp_read_file(connection, path, max_bytes=limit)
|
|
1807
|
+
except TypeError as exc:
|
|
1808
|
+
if "max_bytes" not in str(exc):
|
|
1809
|
+
raise
|
|
1810
|
+
return await sftp_read_file(connection, path)
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
async def _handle_control_message(
|
|
1814
|
+
payload: str,
|
|
1815
|
+
process,
|
|
1816
|
+
connection: asyncssh.SSHClientConnection | None,
|
|
1817
|
+
session: str,
|
|
1818
|
+
transport: str,
|
|
1819
|
+
) -> None:
|
|
1820
|
+
try:
|
|
1821
|
+
message = json.loads(payload)
|
|
1822
|
+
except json.JSONDecodeError:
|
|
1823
|
+
return
|
|
1824
|
+
|
|
1825
|
+
operation = message.get("op")
|
|
1826
|
+
if operation == "resize":
|
|
1827
|
+
cols = int(message.get("cols", 0) or 0)
|
|
1828
|
+
rows = int(message.get("rows", 0) or 0)
|
|
1829
|
+
if cols > 0 and rows > 0 and transport != "local":
|
|
1830
|
+
try:
|
|
1831
|
+
process.set_pty_size(cols, rows)
|
|
1832
|
+
except Exception:
|
|
1833
|
+
pass
|
|
1834
|
+
elif operation == "select-window":
|
|
1835
|
+
target = message.get("target")
|
|
1836
|
+
if target is not None:
|
|
1837
|
+
if transport == "local":
|
|
1838
|
+
await _run_local_tmux_command(["select-window", "-t", f"{session}:{target}"])
|
|
1839
|
+
elif connection is not None:
|
|
1840
|
+
try:
|
|
1841
|
+
await connection.run(
|
|
1842
|
+
f"tmux select-window -t {shlex.quote(session)}:{shlex.quote(str(target))}",
|
|
1843
|
+
check=False,
|
|
1844
|
+
)
|
|
1845
|
+
except Exception:
|
|
1846
|
+
pass
|
|
1847
|
+
elif operation == "send":
|
|
1848
|
+
data = message.get("data")
|
|
1849
|
+
if data:
|
|
1850
|
+
try:
|
|
1851
|
+
process.stdin.write(data.encode())
|
|
1852
|
+
except Exception:
|
|
1853
|
+
pass
|
|
1854
|
+
elif operation == "rename-window":
|
|
1855
|
+
new_name = message.get("target")
|
|
1856
|
+
if new_name:
|
|
1857
|
+
if transport == "local":
|
|
1858
|
+
await _run_local_tmux_command(["rename-window", "-t", session, str(new_name)])
|
|
1859
|
+
elif connection is not None:
|
|
1860
|
+
try:
|
|
1861
|
+
rename_command = (
|
|
1862
|
+
f"tmux rename-window -t {shlex.quote(session)} {shlex.quote(str(new_name))}"
|
|
1863
|
+
)
|
|
1864
|
+
await connection.run(rename_command, check=False)
|
|
1865
|
+
except Exception:
|
|
1866
|
+
pass
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
async def _list_tmux_windows(
|
|
1870
|
+
connection: asyncssh.SSHClientConnection, session: str
|
|
1871
|
+
) -> list[dict[str, str]] | None:
|
|
1872
|
+
try:
|
|
1873
|
+
result = await connection.run(
|
|
1874
|
+
"tmux list-windows -F '#{window_index} #{window_name} #{window_active}' -t "
|
|
1875
|
+
f"{shlex.quote(session)}",
|
|
1876
|
+
check=False,
|
|
1877
|
+
)
|
|
1878
|
+
except Exception:
|
|
1879
|
+
return None
|
|
1880
|
+
|
|
1881
|
+
if result.returncode != 0:
|
|
1882
|
+
return None
|
|
1883
|
+
|
|
1884
|
+
windows: list[dict[str, str]] = []
|
|
1885
|
+
for line in result.stdout.splitlines():
|
|
1886
|
+
parts = line.split(" ", 2)
|
|
1887
|
+
if len(parts) < 3:
|
|
1888
|
+
continue
|
|
1889
|
+
index, name, active = parts
|
|
1890
|
+
windows.append(
|
|
1891
|
+
{
|
|
1892
|
+
"index": index,
|
|
1893
|
+
"name": name,
|
|
1894
|
+
"active": active == "1",
|
|
1895
|
+
}
|
|
1896
|
+
)
|
|
1897
|
+
return windows
|