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/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