kokokoasd commited on
Commit
daaa6ed
·
verified ·
1 Parent(s): 5cf5e60

Upload 15 files

Browse files
Files changed (12) hide show
  1. app.py +14 -162
  2. config.py +26 -0
  3. routers/__init__.py +21 -0
  4. routers/files.py +130 -0
  5. routers/ports.py +70 -0
  6. routers/proxy.py +150 -0
  7. routers/terminal.py +187 -0
  8. routers/zones.py +71 -0
  9. static/app.js +98 -7
  10. static/index.html +31 -3
  11. static/style.css +413 -0
  12. storage.py +48 -0
app.py CHANGED
@@ -1,181 +1,33 @@
1
- import os
2
- import json
3
- import shutil
4
- from pathlib import Path
 
 
 
5
  from contextlib import asynccontextmanager
6
 
7
  import uvicorn
8
- from fastapi import FastAPI, Request, WebSocket, UploadFile, File, Form, HTTPException, Query
9
  from fastapi.staticfiles import StaticFiles
10
- from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
11
 
12
- import zones
13
- import proxy
14
- from terminal import terminal_ws, kill_terminal, active_terminals
15
 
16
 
17
  @asynccontextmanager
18
  async def lifespan(app: FastAPI):
19
  yield
20
- # Cleanup terminals khi shutdown
21
  for zone_name in list(active_terminals.keys()):
22
  kill_terminal(zone_name)
23
 
24
 
25
  app = FastAPI(title="HugPanel", lifespan=lifespan)
26
 
27
-
28
- # ── Zone API ──────────────────────────────────────────────
29
-
30
- @app.get("/api/zones")
31
- def api_list_zones():
32
- return zones.list_zones()
33
-
34
-
35
- @app.post("/api/zones")
36
- def api_create_zone(name: str = Form(...), description: str = Form("")):
37
- try:
38
- result = zones.create_zone(name, description)
39
- return result
40
- except ValueError as e:
41
- raise HTTPException(400, str(e))
42
-
43
-
44
- @app.delete("/api/zones/{zone_name}")
45
- def api_delete_zone(zone_name: str):
46
- try:
47
- kill_terminal(zone_name)
48
- zones.delete_zone(zone_name)
49
- return {"ok": True}
50
- except ValueError as e:
51
- raise HTTPException(400, str(e))
52
-
53
-
54
- # ── File Manager API ─────────────────────────────────────
55
-
56
- @app.get("/api/zones/{zone_name}/files")
57
- def api_list_files(zone_name: str, path: str = Query("")):
58
- try:
59
- return zones.list_files(zone_name, path)
60
- except ValueError as e:
61
- raise HTTPException(400, str(e))
62
-
63
-
64
- @app.get("/api/zones/{zone_name}/files/read")
65
- def api_read_file(zone_name: str, path: str = Query(...)):
66
- try:
67
- content = zones.read_file(zone_name, path)
68
- return {"content": content, "path": path}
69
- except ValueError as e:
70
- raise HTTPException(400, str(e))
71
-
72
-
73
- @app.get("/api/zones/{zone_name}/files/download")
74
- def api_download_file(zone_name: str, path: str = Query(...)):
75
- try:
76
- zone_path = zones.get_zone_path(zone_name)
77
- target = zones._safe_path(zone_path, path)
78
- if not target.is_file():
79
- raise HTTPException(404, "File không tồn tại")
80
- return FileResponse(target, filename=target.name)
81
- except ValueError as e:
82
- raise HTTPException(400, str(e))
83
-
84
-
85
- @app.post("/api/zones/{zone_name}/files/write")
86
- def api_write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
87
- try:
88
- zones.write_file(zone_name, path, content)
89
- return {"ok": True}
90
- except ValueError as e:
91
- raise HTTPException(400, str(e))
92
-
93
-
94
- @app.post("/api/zones/{zone_name}/files/mkdir")
95
- def api_create_folder(zone_name: str, path: str = Form(...)):
96
- try:
97
- zones.create_folder(zone_name, path)
98
- return {"ok": True}
99
- except ValueError as e:
100
- raise HTTPException(400, str(e))
101
-
102
-
103
- @app.post("/api/zones/{zone_name}/files/upload")
104
- async def api_upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...)):
105
- try:
106
- zone_path = zones.get_zone_path(zone_name)
107
- dest = zones._safe_path(zone_path, os.path.join(path, file.filename))
108
- dest.parent.mkdir(parents=True, exist_ok=True)
109
- content = await file.read()
110
- dest.write_bytes(content)
111
- return {"ok": True, "path": str(dest.relative_to(zone_path))}
112
- except ValueError as e:
113
- raise HTTPException(400, str(e))
114
-
115
-
116
- @app.delete("/api/zones/{zone_name}/files")
117
- def api_delete_file(zone_name: str, path: str = Query(...)):
118
- try:
119
- zones.delete_file(zone_name, path)
120
- return {"ok": True}
121
- except ValueError as e:
122
- raise HTTPException(400, str(e))
123
-
124
-
125
- @app.post("/api/zones/{zone_name}/files/rename")
126
- def api_rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...)):
127
- try:
128
- zones.rename_item(zone_name, old_path, new_name)
129
- return {"ok": True}
130
- except ValueError as e:
131
- raise HTTPException(400, str(e))
132
-
133
-
134
- # ── Port Management API ───────────────────────────────────
135
-
136
- @app.get("/api/zones/{zone_name}/ports")
137
- def api_list_ports(zone_name: str):
138
- try:
139
- return proxy.list_ports(zone_name)
140
- except ValueError as e:
141
- raise HTTPException(400, str(e))
142
-
143
-
144
- @app.post("/api/zones/{zone_name}/ports")
145
- def api_add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
146
- try:
147
- return proxy.add_port(zone_name, port, label)
148
- except ValueError as e:
149
- raise HTTPException(400, str(e))
150
-
151
-
152
- @app.delete("/api/zones/{zone_name}/ports/{port}")
153
- def api_remove_port(zone_name: str, port: int):
154
- try:
155
- proxy.remove_port(zone_name, port)
156
- return {"ok": True}
157
- except ValueError as e:
158
- raise HTTPException(400, str(e))
159
-
160
-
161
- # ── Reverse Proxy ────────────────────────────────────────
162
-
163
- @app.api_route("/port/{zone_name}/{port}/{subpath:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
164
- async def proxy_route(request: Request, zone_name: str, port: int, subpath: str = ""):
165
- return await proxy.proxy_http(request, zone_name, port, subpath)
166
-
167
-
168
- @app.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
169
- async def proxy_ws_route(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
170
- await proxy.proxy_ws(websocket, zone_name, port, f"ws/{subpath}")
171
-
172
-
173
- # ── Terminal WebSocket ────────────────────────────────────
174
-
175
- @app.websocket("/ws/terminal/{zone_name}")
176
- async def ws_terminal(websocket: WebSocket, zone_name: str):
177
- await terminal_ws(websocket, zone_name)
178
-
179
 
180
  # ── Static Files & SPA ──────────────────────────────────
181
 
 
1
+ """
2
+ HugPanel — Multi-zone workspace for HuggingFace Spaces.
3
+
4
+ Open/Closed Principle: routers are auto-discovered from the routers/ package.
5
+ Adding a new feature = adding a new file in routers/ — no changes here.
6
+ """
7
+
8
  from contextlib import asynccontextmanager
9
 
10
  import uvicorn
11
+ from fastapi import FastAPI
12
  from fastapi.staticfiles import StaticFiles
13
+ from fastapi.responses import FileResponse
14
 
15
+ from routers import discover_routers
16
+ from routers.terminal import active_terminals, kill_terminal
 
17
 
18
 
19
  @asynccontextmanager
20
  async def lifespan(app: FastAPI):
21
  yield
 
22
  for zone_name in list(active_terminals.keys()):
23
  kill_terminal(zone_name)
24
 
25
 
26
  app = FastAPI(title="HugPanel", lifespan=lifespan)
27
 
28
+ # Auto-register all routers (Open/Closed — new router files are picked up automatically)
29
+ for router in discover_routers():
30
+ app.include_router(router)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  # ── Static Files & SPA ──────────────────────────────────
33
 
config.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized configuration for HugPanel.
3
+
4
+ Single Responsibility: all constants and paths live here.
5
+ Dependency Inversion: other modules depend on this abstraction, not on each other.
6
+ """
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+
12
+ # ── Paths ──────────────────────────────────────
13
+ DATA_DIR = Path(os.environ.get("DATA_DIR", "/data/zones"))
14
+ ZONES_META = DATA_DIR.parent / "zones_meta.json"
15
+
16
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
17
+
18
+ # ── Validation ─────────────────────────────────
19
+ ZONE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,50}$")
20
+
21
+ # ── Port Limits ────────────────────────────────
22
+ MIN_PORT = 1024
23
+ MAX_PORT = 65535
24
+
25
+ # ── Terminal ───────────────────────────────────
26
+ SCROLLBACK_SIZE = 128 * 1024 # 128 KB
routers/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auto-discovery of API routers.
3
+
4
+ Open/Closed Principle: adding a new feature = adding a new file in this package.
5
+ No need to modify app.py — routers are discovered automatically.
6
+ """
7
+
8
+ import importlib
9
+ import pkgutil
10
+
11
+ from fastapi import APIRouter
12
+
13
+
14
+ def discover_routers() -> list[APIRouter]:
15
+ """Scan this package for modules exposing a `router` attribute."""
16
+ routers = []
17
+ for _, modname, _ in pkgutil.iter_modules(__path__):
18
+ mod = importlib.import_module(f".{modname}", __package__)
19
+ if hasattr(mod, "router"):
20
+ routers.append(mod.router)
21
+ return routers
routers/files.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File management API.
3
+
4
+ Single Responsibility: only handles file CRUD operations within zones.
5
+ Separated from zone management (zones.py) — each has its own reason to change.
6
+ """
7
+
8
+ import os
9
+ import shutil
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ from fastapi import APIRouter, Form, File, UploadFile, Query, HTTPException
14
+ from fastapi.responses import FileResponse
15
+
16
+ from storage import get_zone_path, safe_path
17
+
18
+ router = APIRouter(prefix="/api/zones/{zone_name}/files", tags=["files"])
19
+
20
+
21
+ @router.get("")
22
+ def list_files(zone_name: str, path: str = Query("")):
23
+ try:
24
+ zone_path = get_zone_path(zone_name)
25
+ target = safe_path(zone_path, path)
26
+ if not target.is_dir():
27
+ raise ValueError("Không phải thư mục")
28
+ return [
29
+ {
30
+ "name": item.name,
31
+ "is_dir": item.is_dir(),
32
+ "size": item.stat().st_size if item.is_file() else 0,
33
+ "modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
34
+ }
35
+ for item in sorted(target.iterdir())
36
+ ]
37
+ except ValueError as e:
38
+ raise HTTPException(400, str(e))
39
+
40
+
41
+ @router.get("/read")
42
+ def read_file(zone_name: str, path: str = Query(...)):
43
+ try:
44
+ zone_path = get_zone_path(zone_name)
45
+ target = safe_path(zone_path, path)
46
+ if not target.is_file():
47
+ raise ValueError("File không tồn tại")
48
+ return {"content": target.read_text(encoding="utf-8", errors="replace"), "path": path}
49
+ except ValueError as e:
50
+ raise HTTPException(400, str(e))
51
+
52
+
53
+ @router.get("/download")
54
+ def download_file(zone_name: str, path: str = Query(...)):
55
+ try:
56
+ zone_path = get_zone_path(zone_name)
57
+ target = safe_path(zone_path, path)
58
+ if not target.is_file():
59
+ raise HTTPException(404, "File không tồn tại")
60
+ return FileResponse(target, filename=target.name)
61
+ except ValueError as e:
62
+ raise HTTPException(400, str(e))
63
+
64
+
65
+ @router.post("/write")
66
+ def write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
67
+ try:
68
+ zone_path = get_zone_path(zone_name)
69
+ target = safe_path(zone_path, path)
70
+ target.parent.mkdir(parents=True, exist_ok=True)
71
+ target.write_text(content, encoding="utf-8")
72
+ return {"ok": True}
73
+ except ValueError as e:
74
+ raise HTTPException(400, str(e))
75
+
76
+
77
+ @router.post("/mkdir")
78
+ def create_folder(zone_name: str, path: str = Form(...)):
79
+ try:
80
+ zone_path = get_zone_path(zone_name)
81
+ target = safe_path(zone_path, path)
82
+ target.mkdir(parents=True, exist_ok=True)
83
+ return {"ok": True}
84
+ except ValueError as e:
85
+ raise HTTPException(400, str(e))
86
+
87
+
88
+ @router.post("/upload")
89
+ async def upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...)):
90
+ try:
91
+ zone_path = get_zone_path(zone_name)
92
+ dest = safe_path(zone_path, os.path.join(path, file.filename))
93
+ dest.parent.mkdir(parents=True, exist_ok=True)
94
+ content = await file.read()
95
+ dest.write_bytes(content)
96
+ return {"ok": True, "path": str(dest.relative_to(zone_path))}
97
+ except ValueError as e:
98
+ raise HTTPException(400, str(e))
99
+
100
+
101
+ @router.delete("")
102
+ def delete_file(zone_name: str, path: str = Query(...)):
103
+ try:
104
+ zone_path = get_zone_path(zone_name)
105
+ target = safe_path(zone_path, path)
106
+ if target == zone_path.resolve():
107
+ raise ValueError("Không thể xoá thư mục gốc zone")
108
+ if target.is_dir():
109
+ shutil.rmtree(target)
110
+ elif target.is_file():
111
+ target.unlink()
112
+ else:
113
+ raise ValueError("File/thư mục không tồn tại")
114
+ return {"ok": True}
115
+ except ValueError as e:
116
+ raise HTTPException(400, str(e))
117
+
118
+
119
+ @router.post("/rename")
120
+ def rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...)):
121
+ try:
122
+ zone_path = get_zone_path(zone_name)
123
+ source = safe_path(zone_path, old_path)
124
+ if not source.exists():
125
+ raise ValueError("File/thư mục nguồn không tồn tại")
126
+ dest = safe_path(zone_path, str(Path(old_path).parent / new_name))
127
+ source.rename(dest)
128
+ return {"ok": True}
129
+ except ValueError as e:
130
+ raise HTTPException(400, str(e))
routers/ports.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Port management API.
3
+
4
+ Single Responsibility: only handles port CRUD for zones.
5
+ Reverse proxy logic is in proxy.py — separate reason to change.
6
+ """
7
+
8
+ from fastapi import APIRouter, Form, HTTPException
9
+
10
+ from config import MIN_PORT, MAX_PORT
11
+ from storage import load_meta, save_meta
12
+
13
+ router = APIRouter(prefix="/api/zones/{zone_name}/ports", tags=["ports"])
14
+
15
+
16
+ def _validate_port(port: int):
17
+ if not (MIN_PORT <= port <= MAX_PORT):
18
+ raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
19
+
20
+
21
+ def _validate_zone(meta: dict, zone_name: str):
22
+ if zone_name not in meta:
23
+ raise ValueError(f"Zone '{zone_name}' does not exist")
24
+
25
+
26
+ @router.get("")
27
+ def list_ports(zone_name: str):
28
+ try:
29
+ meta = load_meta()
30
+ _validate_zone(meta, zone_name)
31
+ return meta[zone_name].get("ports", [])
32
+ except ValueError as e:
33
+ raise HTTPException(400, str(e))
34
+
35
+
36
+ @router.post("")
37
+ def add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
38
+ try:
39
+ _validate_port(port)
40
+ meta = load_meta()
41
+ _validate_zone(meta, zone_name)
42
+
43
+ ports = meta[zone_name].setdefault("ports", [])
44
+ for p in ports:
45
+ if p["port"] == port:
46
+ raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
47
+
48
+ entry = {"port": port, "label": label or f"Port {port}"}
49
+ ports.append(entry)
50
+ save_meta(meta)
51
+ return entry
52
+ except ValueError as e:
53
+ raise HTTPException(400, str(e))
54
+
55
+
56
+ @router.delete("/{port}")
57
+ def remove_port(zone_name: str, port: int):
58
+ try:
59
+ meta = load_meta()
60
+ _validate_zone(meta, zone_name)
61
+
62
+ ports = meta[zone_name].get("ports", [])
63
+ before = len(ports)
64
+ meta[zone_name]["ports"] = [p for p in ports if p["port"] != port]
65
+ if len(meta[zone_name]["ports"]) == before:
66
+ raise ValueError(f"Port {port} not found in zone '{zone_name}'")
67
+ save_meta(meta)
68
+ return {"ok": True}
69
+ except ValueError as e:
70
+ raise HTTPException(400, str(e))
routers/proxy.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reverse proxy for virtual ports.
3
+
4
+ Single Responsibility: only handles HTTP/WebSocket proxying.
5
+ Port CRUD is in ports.py — separate concern.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+
11
+ import httpx
12
+ from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
13
+ from fastapi.responses import Response
14
+
15
+ from config import MIN_PORT, MAX_PORT
16
+ from storage import load_meta
17
+
18
+ router = APIRouter(tags=["proxy"])
19
+
20
+ # ── Shared HTTP client ────────────────────────
21
+
22
+ _HOP_HEADERS = frozenset({
23
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
24
+ "te", "trailers", "transfer-encoding", "upgrade",
25
+ })
26
+
27
+ _client: httpx.AsyncClient | None = None
28
+
29
+
30
+ def _get_client() -> httpx.AsyncClient:
31
+ global _client
32
+ if _client is None:
33
+ _client = httpx.AsyncClient(
34
+ timeout=httpx.Timeout(30.0, connect=5.0),
35
+ follow_redirects=False,
36
+ limits=httpx.Limits(max_connections=50),
37
+ )
38
+ return _client
39
+
40
+
41
+ def _validate_proxy_access(zone_name: str, port: int):
42
+ """Validate port range and check it's registered for the zone."""
43
+ if not (MIN_PORT <= port <= MAX_PORT):
44
+ raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
45
+ meta = load_meta()
46
+ if zone_name not in meta:
47
+ raise ValueError(f"Zone '{zone_name}' does not exist")
48
+ ports = meta[zone_name].get("ports", [])
49
+ if not any(p["port"] == port for p in ports):
50
+ raise ValueError("Port not mapped")
51
+
52
+
53
+ # ── HTTP Reverse Proxy ────────────────────────
54
+
55
+ @router.api_route(
56
+ "/port/{zone_name}/{port}/{subpath:path}",
57
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
58
+ )
59
+ async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = ""):
60
+ try:
61
+ _validate_proxy_access(zone_name, port)
62
+ except ValueError:
63
+ return Response(content="Port not mapped", status_code=404)
64
+
65
+ target_url = f"http://127.0.0.1:{port}/{subpath}"
66
+ if request.url.query:
67
+ target_url += f"?{request.url.query}"
68
+
69
+ headers = {}
70
+ for key, value in request.headers.items():
71
+ if key.lower() not in _HOP_HEADERS and key.lower() != "host":
72
+ headers[key] = value
73
+ headers["host"] = f"127.0.0.1:{port}"
74
+ headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
75
+ headers["x-forwarded-proto"] = request.url.scheme
76
+ headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
77
+
78
+ body = await request.body()
79
+ client = _get_client()
80
+
81
+ try:
82
+ resp = await client.request(method=request.method, url=target_url, headers=headers, content=body)
83
+ except httpx.ConnectError:
84
+ return Response(
85
+ content=f"Cannot connect to port {port}. Make sure your server is running.",
86
+ status_code=502,
87
+ media_type="text/plain",
88
+ )
89
+ except httpx.TimeoutException:
90
+ return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
91
+
92
+ resp_headers = {}
93
+ for key, value in resp.headers.items():
94
+ if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
95
+ resp_headers[key] = value
96
+
97
+ return Response(content=resp.content, status_code=resp.status_code, headers=resp_headers)
98
+
99
+
100
+ # ── WebSocket Reverse Proxy ──────────────────
101
+
102
+ @router.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
103
+ async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
104
+ try:
105
+ _validate_proxy_access(zone_name, port)
106
+ except ValueError:
107
+ await websocket.close(code=4004, reason="Port not mapped")
108
+ return
109
+
110
+ await websocket.accept()
111
+ target_url = f"ws://127.0.0.1:{port}/ws/{subpath}"
112
+
113
+ import websockets as ws_lib
114
+
115
+ try:
116
+ async with ws_lib.connect(target_url) as backend_ws:
117
+ async def client_to_backend():
118
+ try:
119
+ while True:
120
+ msg = await websocket.receive()
121
+ if msg.get("type") == "websocket.disconnect":
122
+ break
123
+ if "text" in msg:
124
+ await backend_ws.send(msg["text"])
125
+ elif "bytes" in msg:
126
+ await backend_ws.send(msg["bytes"])
127
+ except (WebSocketDisconnect, Exception):
128
+ pass
129
+
130
+ async def backend_to_client():
131
+ try:
132
+ async for message in backend_ws:
133
+ if isinstance(message, str):
134
+ await websocket.send_text(message)
135
+ else:
136
+ await websocket.send_bytes(message)
137
+ except (WebSocketDisconnect, Exception):
138
+ pass
139
+
140
+ await asyncio.gather(client_to_backend(), backend_to_client())
141
+ except Exception:
142
+ try:
143
+ await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
144
+ except Exception:
145
+ pass
146
+ finally:
147
+ try:
148
+ await websocket.close()
149
+ except Exception:
150
+ pass
routers/terminal.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Terminal WebSocket with persistent PTY sessions.
3
+
4
+ Single Responsibility: only handles PTY lifecycle and WebSocket communication.
5
+ Depends on storage.get_zone_path for path resolution (Dependency Inversion).
6
+ """
7
+
8
+ import asyncio
9
+ import collections
10
+ import fcntl
11
+ import json
12
+ import os
13
+ import pty
14
+ import select
15
+ import struct
16
+ import termios
17
+
18
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
19
+
20
+ from config import SCROLLBACK_SIZE
21
+ from storage import get_zone_path
22
+
23
+ router = APIRouter(tags=["terminal"])
24
+
25
+ # Active terminals: {zone_name: {fd, pid, buffer, buffer_size, bg_task, ws}}
26
+ active_terminals: dict[str, dict] = {}
27
+
28
+
29
+ # ── PTY Management ────────────────────────────
30
+
31
+ def _spawn_shell(zone_name: str) -> dict:
32
+ """Spawn a new PTY shell for a zone."""
33
+ zone_path = get_zone_path(zone_name)
34
+ master_fd, slave_fd = pty.openpty()
35
+
36
+ child_pid = os.fork()
37
+ if child_pid == 0:
38
+ os.setsid()
39
+ os.dup2(slave_fd, 0)
40
+ os.dup2(slave_fd, 1)
41
+ os.dup2(slave_fd, 2)
42
+ os.close(master_fd)
43
+ os.close(slave_fd)
44
+ os.chdir(str(zone_path))
45
+ env = os.environ.copy()
46
+ env["TERM"] = "xterm-256color"
47
+ env["HOME"] = str(zone_path)
48
+ env["PS1"] = f"[{zone_name}] \\w $ "
49
+ os.execvpe("/bin/bash", ["/bin/bash", "--norc"], env)
50
+ else:
51
+ os.close(slave_fd)
52
+ flag = fcntl.fcntl(master_fd, fcntl.F_GETFL)
53
+ fcntl.fcntl(master_fd, fcntl.F_SETFL, flag | os.O_NONBLOCK)
54
+ return {"fd": master_fd, "pid": child_pid, "buffer": collections.deque(), "buffer_size": 0}
55
+
56
+
57
+ def _resize_terminal(zone_name: str, rows: int, cols: int):
58
+ if zone_name in active_terminals:
59
+ fd = active_terminals[zone_name]["fd"]
60
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
61
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
62
+
63
+
64
+ def _append_buffer(info: dict, data: bytes):
65
+ info["buffer"].append(data)
66
+ info["buffer_size"] += len(data)
67
+ while info["buffer_size"] > SCROLLBACK_SIZE:
68
+ old = info["buffer"].popleft()
69
+ info["buffer_size"] -= len(old)
70
+
71
+
72
+ def _get_buffer(info: dict) -> bytes:
73
+ return b"".join(info["buffer"])
74
+
75
+
76
+ def _is_alive(zone_name: str) -> bool:
77
+ if zone_name not in active_terminals:
78
+ return False
79
+ try:
80
+ pid = active_terminals[zone_name]["pid"]
81
+ return os.waitpid(pid, os.WNOHANG) == (0, 0)
82
+ except ChildProcessError:
83
+ active_terminals.pop(zone_name, None)
84
+ return False
85
+
86
+
87
+ async def _bg_reader(zone_name: str):
88
+ """Background: continuously read PTY output into the ring buffer."""
89
+ info = active_terminals.get(zone_name)
90
+ if not info:
91
+ return
92
+ fd = info["fd"]
93
+ while _is_alive(zone_name):
94
+ await asyncio.sleep(0.02)
95
+ try:
96
+ r, _, _ = select.select([fd], [], [], 0)
97
+ if r:
98
+ data = os.read(fd, 4096)
99
+ if data:
100
+ _append_buffer(info, data)
101
+ ws = info.get("ws")
102
+ if ws:
103
+ try:
104
+ await ws.send_bytes(data)
105
+ except Exception:
106
+ info["ws"] = None
107
+ except (OSError, BlockingIOError):
108
+ pass
109
+ except Exception:
110
+ break
111
+
112
+
113
+ def kill_terminal(zone_name: str):
114
+ """Kill terminal process for a zone."""
115
+ if zone_name in active_terminals:
116
+ info = active_terminals.pop(zone_name)
117
+ bg = info.get("bg_task")
118
+ if bg:
119
+ bg.cancel()
120
+ try:
121
+ os.kill(info["pid"], 9)
122
+ os.waitpid(info["pid"], os.WNOHANG)
123
+ except (ProcessLookupError, ChildProcessError):
124
+ pass
125
+ try:
126
+ os.close(info["fd"])
127
+ except OSError:
128
+ pass
129
+
130
+
131
+ # ── WebSocket Handler ─────────────────────────
132
+
133
+ @router.websocket("/ws/terminal/{zone_name}")
134
+ async def terminal_ws(websocket: WebSocket, zone_name: str):
135
+ await websocket.accept()
136
+
137
+ try:
138
+ get_zone_path(zone_name)
139
+ except ValueError as e:
140
+ await websocket.send_json({"error": str(e)})
141
+ await websocket.close()
142
+ return
143
+
144
+ # Spawn or reuse terminal
145
+ if not _is_alive(zone_name):
146
+ kill_terminal(zone_name)
147
+ try:
148
+ info = _spawn_shell(zone_name)
149
+ info["ws"] = None
150
+ active_terminals[zone_name] = info
151
+ info["bg_task"] = asyncio.create_task(_bg_reader(zone_name))
152
+ except Exception as e:
153
+ await websocket.send_json({"error": f"Cannot create terminal: {e}"})
154
+ await websocket.close()
155
+ return
156
+
157
+ info = active_terminals[zone_name]
158
+ fd = info["fd"]
159
+
160
+ # Replay buffered scrollback
161
+ buf = _get_buffer(info)
162
+ if buf:
163
+ await websocket.send_bytes(buf)
164
+
165
+ # Register this WebSocket as the active receiver
166
+ info["ws"] = websocket
167
+
168
+ try:
169
+ while True:
170
+ msg = await websocket.receive()
171
+ if msg.get("type") == "websocket.disconnect":
172
+ break
173
+ if "text" in msg:
174
+ data = json.loads(msg["text"])
175
+ if data.get("type") == "resize":
176
+ _resize_terminal(zone_name, data.get("rows", 24), data.get("cols", 80))
177
+ elif data.get("type") == "input":
178
+ os.write(fd, data["data"].encode("utf-8"))
179
+ elif "bytes" in msg:
180
+ os.write(fd, msg["bytes"])
181
+ except WebSocketDisconnect:
182
+ pass
183
+ except Exception:
184
+ pass
185
+ finally:
186
+ if zone_name in active_terminals and active_terminals[zone_name].get("ws") is websocket:
187
+ active_terminals[zone_name]["ws"] = None
routers/zones.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Zone CRUD API.
3
+
4
+ Single Responsibility: only handles zone create/list/delete.
5
+ File management is in files.py, port management in ports.py.
6
+ """
7
+
8
+ import shutil
9
+ from datetime import datetime
10
+
11
+ from fastapi import APIRouter, Form, HTTPException
12
+
13
+ from config import DATA_DIR
14
+ from storage import load_meta, save_meta, validate_zone_name
15
+ from routers.terminal import kill_terminal
16
+
17
+ router = APIRouter(prefix="/api/zones", tags=["zones"])
18
+
19
+
20
+ @router.get("")
21
+ def list_zones():
22
+ meta = load_meta()
23
+ return [
24
+ {
25
+ "name": name,
26
+ "created": info.get("created", ""),
27
+ "description": info.get("description", ""),
28
+ "exists": (DATA_DIR / name).is_dir(),
29
+ }
30
+ for name, info in meta.items()
31
+ ]
32
+
33
+
34
+ @router.post("")
35
+ def create_zone(name: str = Form(...), description: str = Form("")):
36
+ try:
37
+ validate_zone_name(name)
38
+ zone_path = DATA_DIR / name
39
+ if zone_path.exists():
40
+ raise ValueError(f"Zone '{name}' đã tồn tại")
41
+
42
+ zone_path.mkdir(parents=True)
43
+ (zone_path / "README.md").write_text(
44
+ f"# {name}\n\nZone được tạo lúc {datetime.now().isoformat()}\n"
45
+ )
46
+
47
+ meta = load_meta()
48
+ meta[name] = {"created": datetime.now().isoformat(), "description": description}
49
+ save_meta(meta)
50
+ return {"name": name, "path": str(zone_path)}
51
+ except ValueError as e:
52
+ raise HTTPException(400, str(e))
53
+
54
+
55
+ @router.delete("/{zone_name}")
56
+ def delete_zone(zone_name: str):
57
+ try:
58
+ validate_zone_name(zone_name)
59
+ zone_path = DATA_DIR / zone_name
60
+ if not zone_path.exists():
61
+ raise ValueError(f"Zone '{zone_name}' không tồn tại")
62
+
63
+ kill_terminal(zone_name)
64
+ shutil.rmtree(zone_path)
65
+
66
+ meta = load_meta()
67
+ meta.pop(zone_name, None)
68
+ save_meta(meta)
69
+ return {"ok": True}
70
+ except ValueError as e:
71
+ raise HTTPException(400, str(e))
static/app.js CHANGED
@@ -14,6 +14,8 @@ let termDataDisposable = null;
14
  let termResizeDisposable = null;
15
  let termCurrentZone = null; // tracks which zone the terminal is connected to
16
  let promptResolve = null;
 
 
17
 
18
  // ── Init ──────────────────────────────────────
19
  document.addEventListener("DOMContentLoaded", () => {
@@ -21,6 +23,27 @@ document.addEventListener("DOMContentLoaded", () => {
21
  loadZones();
22
  initResizers();
23
  bindEvents();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  });
25
 
26
  // ── API ───────────────────────────────────────
@@ -77,7 +100,7 @@ async function openZone(name) {
77
 
78
  // Reset editor
79
  const editorContainer = document.getElementById("editor-container");
80
- editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>Double-click a file to open</p></div>`;
81
  document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
82
  lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
83
  cmEditor = null;
@@ -90,6 +113,12 @@ async function openZone(name) {
90
  await loadFiles();
91
  loadPorts();
92
 
 
 
 
 
 
 
93
  // Auto-connect terminal
94
  setTimeout(() => connectTerminal(), 200);
95
  }
@@ -139,7 +168,6 @@ async function loadFiles() {
139
  }
140
 
141
  function renderBreadcrumb() {
142
- const bc = document.getElementById("breadcrumb");
143
  const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
144
  let html = `<span onclick="navigateTo('')">~</span>`;
145
  let path = "";
@@ -148,7 +176,10 @@ function renderBreadcrumb() {
148
  html += `<span class="sep">/</span>`;
149
  html += `<span onclick="navigateTo('${escapeAttr(path)}')">${escapeHtml(part)}</span>`;
150
  }
151
- bc.innerHTML = html;
 
 
 
152
  }
153
 
154
  function renderFiles(files) {
@@ -160,7 +191,7 @@ function renderFiles(files) {
160
  }
161
  let html = "";
162
  if (currentPath) {
163
- html += `<div class="file-item" ondblclick="navigateUp()">
164
  <span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
165
  <span class="fi-name">..</span>
166
  </div>`;
@@ -174,8 +205,10 @@ function renderFiles(files) {
174
  const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
175
  const iconName = f.is_dir ? "folder" : "file-text";
176
  const size = f.is_dir ? "" : formatSize(f.size);
 
 
177
 
178
- html += `<div class="file-item" ondblclick="${f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`}">
179
  <span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
180
  <span class="fi-name">${escapeHtml(f.name)}</span>
181
  <span class="fi-size">${size}</span>
@@ -246,7 +279,7 @@ async function editFile(relPath) {
246
  mode: getMode(filename),
247
  theme: "material-darker",
248
  lineNumbers: true,
249
- lineWrapping: false,
250
  indentWithTabs: false,
251
  indentUnit: 4,
252
  tabSize: 4,
@@ -267,8 +300,13 @@ async function editFile(relPath) {
267
  cmEditor.on("change", () => {
268
  const dot = document.getElementById("editor-modified");
269
  if (dot) dot.style.display = "block";
 
 
270
  });
271
 
 
 
 
272
  // Focus editor
273
  setTimeout(() => cmEditor.refresh(), 50);
274
  } catch (e) {
@@ -286,6 +324,8 @@ async function saveFile() {
286
  await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
287
  const dot = document.getElementById("editor-modified");
288
  if (dot) dot.style.display = "none";
 
 
289
  toast("File saved", "success");
290
  } catch (e) {
291
  toast("Save failed: " + e.message, "error");
@@ -382,7 +422,7 @@ function connectTerminal() {
382
  if (!term) {
383
  term = new window.Terminal({
384
  cursorBlink: true,
385
- fontSize: 13,
386
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
387
  theme: {
388
  background: "#09090b",
@@ -680,6 +720,57 @@ function fileIconClass(name) {
680
  return classes[ext] || "fi-icon-file";
681
  }
682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
683
  // ── Event Binding ────────────────────────────
684
  function bindEvents() {
685
  document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
 
14
  let termResizeDisposable = null;
15
  let termCurrentZone = null; // tracks which zone the terminal is connected to
16
  let promptResolve = null;
17
+ let isMobile = window.matchMedia("(max-width: 768px)").matches;
18
+ let currentMobileTab = "files";
19
 
20
  // ── Init ──────────────────────────────────────
21
  document.addEventListener("DOMContentLoaded", () => {
 
23
  loadZones();
24
  initResizers();
25
  bindEvents();
26
+
27
+ // Track mobile state on resize
28
+ const mq = window.matchMedia("(max-width: 768px)");
29
+ mq.addEventListener("change", (e) => {
30
+ isMobile = e.matches;
31
+ if (!isMobile) {
32
+ // Exiting mobile: reset panel visibility
33
+ toggleSidebar(false);
34
+ document.getElementById("panel-files").classList.remove("m-active");
35
+ document.getElementById("pane-editor").classList.remove("m-active");
36
+ document.getElementById("pane-terminal").classList.remove("m-active");
37
+ document.getElementById("panel-right").classList.remove("m-active");
38
+ } else if (currentZone) {
39
+ switchMobileTab(currentMobileTab);
40
+ }
41
+ });
42
+
43
+ // Apply initial mobile tab if on mobile
44
+ if (isMobile && currentZone) {
45
+ switchMobileTab("files");
46
+ }
47
  });
48
 
49
  // ── API ───────────────────────────────────────
 
100
 
101
  // Reset editor
102
  const editorContainer = document.getElementById("editor-container");
103
+ editorContainer.innerHTML = `<div class="editor-empty"><i data-lucide="mouse-pointer-click"></i><p>${isMobile ? 'Tap a file to open' : 'Double-click a file to open'}</p></div>`;
104
  document.getElementById("editor-tabs").innerHTML = `<span class="tab-placeholder"><i data-lucide="code-2"></i> No file open</span>`;
105
  lucide.createIcons({ nodes: [editorContainer, document.getElementById("editor-tabs")] });
106
  cmEditor = null;
 
113
  await loadFiles();
114
  loadPorts();
115
 
116
+ // Mobile: close sidebar, show files tab
117
+ if (isMobile) {
118
+ toggleSidebar(false);
119
+ switchMobileTab("files");
120
+ }
121
+
122
  // Auto-connect terminal
123
  setTimeout(() => connectTerminal(), 200);
124
  }
 
168
  }
169
 
170
  function renderBreadcrumb() {
 
171
  const parts = currentPath ? currentPath.split("/").filter(Boolean) : [];
172
  let html = `<span onclick="navigateTo('')">~</span>`;
173
  let path = "";
 
176
  html += `<span class="sep">/</span>`;
177
  html += `<span onclick="navigateTo('${escapeAttr(path)}')">${escapeHtml(part)}</span>`;
178
  }
179
+ // Update both desktop and mobile breadcrumbs
180
+ document.getElementById("breadcrumb").innerHTML = html;
181
+ const mbc = document.getElementById("breadcrumb-mobile");
182
+ if (mbc) mbc.innerHTML = html;
183
  }
184
 
185
  function renderFiles(files) {
 
191
  }
192
  let html = "";
193
  if (currentPath) {
194
+ html += `<div class="file-item" ondblclick="navigateUp()" onclick="if(isMobile)navigateUp()">
195
  <span class="fi-icon fi-icon-back"><i data-lucide="corner-left-up"></i></span>
196
  <span class="fi-name">..</span>
197
  </div>`;
 
205
  const iconClass = f.is_dir ? "fi-icon-folder" : fileIconClass(f.name);
206
  const iconName = f.is_dir ? "folder" : "file-text";
207
  const size = f.is_dir ? "" : formatSize(f.size);
208
+ const dblAction = f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`;
209
+ const tapAction = f.is_dir ? `navigateTo('${escapeAttr(relPath)}')` : `editFile('${escapeAttr(relPath)}')`;
210
 
211
+ html += `<div class="file-item" ondblclick="${dblAction}" onclick="if(isMobile){${tapAction}}">
212
  <span class="fi-icon ${iconClass}"><i data-lucide="${iconName}"></i></span>
213
  <span class="fi-name">${escapeHtml(f.name)}</span>
214
  <span class="fi-size">${size}</span>
 
279
  mode: getMode(filename),
280
  theme: "material-darker",
281
  lineNumbers: true,
282
+ lineWrapping: isMobile,
283
  indentWithTabs: false,
284
  indentUnit: 4,
285
  tabSize: 4,
 
300
  cmEditor.on("change", () => {
301
  const dot = document.getElementById("editor-modified");
302
  if (dot) dot.style.display = "block";
303
+ const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
304
+ if (mTab) mTab.classList.add('has-dot');
305
  });
306
 
307
+ // Auto-switch to editor tab on mobile
308
+ if (isMobile) switchMobileTab('editor');
309
+
310
  // Focus editor
311
  setTimeout(() => cmEditor.refresh(), 50);
312
  } catch (e) {
 
324
  await api(`/api/zones/${currentZone}/files/write`, { method: "POST", body: form });
325
  const dot = document.getElementById("editor-modified");
326
  if (dot) dot.style.display = "none";
327
+ const mTab = document.querySelector('#mobile-tabs [data-tab="editor"]');
328
+ if (mTab) mTab.classList.remove('has-dot');
329
  toast("File saved", "success");
330
  } catch (e) {
331
  toast("Save failed: " + e.message, "error");
 
422
  if (!term) {
423
  term = new window.Terminal({
424
  cursorBlink: true,
425
+ fontSize: isMobile ? 11 : 13,
426
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
427
  theme: {
428
  background: "#09090b",
 
720
  return classes[ext] || "fi-icon-file";
721
  }
722
 
723
+ // ── Mobile: Sidebar ─────────────────────────
724
+ function toggleSidebar(open) {
725
+ const sidebar = document.getElementById("sidebar");
726
+ const backdrop = document.getElementById("sidebar-backdrop");
727
+ if (open) {
728
+ sidebar.classList.add("open");
729
+ backdrop.classList.add("open");
730
+ } else {
731
+ sidebar.classList.remove("open");
732
+ backdrop.classList.remove("open");
733
+ }
734
+ }
735
+
736
+ // ── Mobile: Tab Switching ───────────────────
737
+ function switchMobileTab(tab) {
738
+ currentMobileTab = tab;
739
+
740
+ // Update tab bar highlighting
741
+ document.querySelectorAll(".mobile-tab").forEach(btn => {
742
+ btn.classList.toggle("active", btn.dataset.tab === tab);
743
+ });
744
+
745
+ const panelFiles = document.getElementById("panel-files");
746
+ const panelRight = document.getElementById("panel-right");
747
+ const paneEditor = document.getElementById("pane-editor");
748
+ const paneTerminal = document.getElementById("pane-terminal");
749
+
750
+ // Remove all active
751
+ panelFiles.classList.remove("m-active");
752
+ panelRight.classList.remove("m-active");
753
+ paneEditor.classList.remove("m-active");
754
+ paneTerminal.classList.remove("m-active");
755
+
756
+ if (tab === "files") {
757
+ panelFiles.classList.add("m-active");
758
+ } else if (tab === "editor") {
759
+ panelRight.classList.add("m-active");
760
+ paneEditor.classList.add("m-active");
761
+ setTimeout(() => { if (cmEditor) cmEditor.refresh(); }, 50);
762
+ } else if (tab === "terminal") {
763
+ panelRight.classList.add("m-active");
764
+ paneTerminal.classList.add("m-active");
765
+ setTimeout(() => {
766
+ if (fitAddon) fitAddon.fit();
767
+ if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
768
+ connectTerminal();
769
+ }
770
+ }, 50);
771
+ }
772
+ }
773
+
774
  // ── Event Binding ────────────────────────────
775
  function bindEvents() {
776
  document.getElementById("btn-add-zone").addEventListener("click", () => showModal("modal-overlay"));
static/index.html CHANGED
@@ -2,8 +2,12 @@
2
  <html lang="vi">
3
  <head>
4
  <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>HugPanel</title>
 
 
 
 
7
  <link rel="stylesheet" href="/static/style.css">
8
  <!-- Lucide Icons -->
9
  <script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
@@ -34,11 +38,15 @@
34
  </head>
35
  <body>
36
  <div id="app">
 
 
 
37
  <!-- Sidebar -->
38
  <aside id="sidebar">
39
  <div class="sidebar-brand">
40
  <div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
41
  <span class="brand-text">HugPanel</span>
 
42
  </div>
43
 
44
  <nav class="sidebar-nav">
@@ -81,12 +89,15 @@
81
  <div id="workspace" class="view">
82
  <div class="topbar">
83
  <div class="topbar-left">
 
 
 
84
  <span class="zone-indicator" id="zone-badge">
85
  <i data-lucide="box"></i>
86
  <span id="zone-title"></span>
87
  </span>
88
- <span class="topbar-sep"></span>
89
- <div class="breadcrumb" id="breadcrumb"></div>
90
  </div>
91
  <div class="topbar-right">
92
  <div class="port-controls" id="port-controls">
@@ -113,6 +124,7 @@
113
  <button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
114
  </div>
115
  </div>
 
116
  <div id="file-list" class="file-tree"></div>
117
  <input type="file" id="file-upload-input" style="display:none" multiple>
118
  </div>
@@ -151,6 +163,22 @@
151
  </div>
152
  </div>
153
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  </div>
155
  </main>
156
  </div>
 
2
  <html lang="vi">
3
  <head>
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
  <title>HugPanel</title>
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
10
+ <meta name="theme-color" content="#09090b">
11
  <link rel="stylesheet" href="/static/style.css">
12
  <!-- Lucide Icons -->
13
  <script src="https://cdn.jsdelivr.net/npm/lucide@0.344.0/dist/umd/lucide.min.js"></script>
 
38
  </head>
39
  <body>
40
  <div id="app">
41
+ <!-- Sidebar Backdrop (mobile) -->
42
+ <div id="sidebar-backdrop" class="sidebar-backdrop" onclick="toggleSidebar(false)"></div>
43
+
44
  <!-- Sidebar -->
45
  <aside id="sidebar">
46
  <div class="sidebar-brand">
47
  <div class="brand-icon"><i data-lucide="layout-dashboard"></i></div>
48
  <span class="brand-text">HugPanel</span>
49
+ <button id="btn-close-sidebar" class="icon-btn-sm sidebar-close-btn" onclick="toggleSidebar(false)"><i data-lucide="x"></i></button>
50
  </div>
51
 
52
  <nav class="sidebar-nav">
 
89
  <div id="workspace" class="view">
90
  <div class="topbar">
91
  <div class="topbar-left">
92
+ <button id="btn-hamburger" class="icon-btn-sm hamburger-btn" onclick="toggleSidebar(true)" title="Menu">
93
+ <i data-lucide="menu"></i>
94
+ </button>
95
  <span class="zone-indicator" id="zone-badge">
96
  <i data-lucide="box"></i>
97
  <span id="zone-title"></span>
98
  </span>
99
+ <span class="topbar-sep desktop-only"></span>
100
+ <div class="breadcrumb desktop-only" id="breadcrumb"></div>
101
  </div>
102
  <div class="topbar-right">
103
  <div class="port-controls" id="port-controls">
 
124
  <button class="icon-btn-sm" id="btn-upload" title="Upload"><i data-lucide="upload"></i></button>
125
  </div>
126
  </div>
127
+ <div class="breadcrumb mobile-breadcrumb mobile-only" id="breadcrumb-mobile"></div>
128
  <div id="file-list" class="file-tree"></div>
129
  <input type="file" id="file-upload-input" style="display:none" multiple>
130
  </div>
 
163
  </div>
164
  </div>
165
  </div>
166
+
167
+ <!-- Mobile Bottom Tab Bar -->
168
+ <nav id="mobile-tabs" class="mobile-tabs">
169
+ <button class="mobile-tab active" data-tab="files" onclick="switchMobileTab('files')">
170
+ <i data-lucide="folder"></i>
171
+ <span>Files</span>
172
+ </button>
173
+ <button class="mobile-tab" data-tab="editor" onclick="switchMobileTab('editor')">
174
+ <i data-lucide="code-2"></i>
175
+ <span>Editor</span>
176
+ </button>
177
+ <button class="mobile-tab" data-tab="terminal" onclick="switchMobileTab('terminal')">
178
+ <i data-lucide="terminal-square"></i>
179
+ <span>Terminal</span>
180
+ </button>
181
+ </nav>
182
  </div>
183
  </main>
184
  </div>
static/style.css CHANGED
@@ -834,5 +834,418 @@ body {
834
  font-size: 12px;
835
  }
836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  /* ── Utility ───────────────── */
838
  .hidden { display: none !important; }
 
834
  font-size: 12px;
835
  }
836
 
837
+ /* ── Mobile Infrastructure ─── */
838
+ .sidebar-backdrop {
839
+ display: none;
840
+ position: fixed;
841
+ inset: 0;
842
+ background: rgba(0,0,0,0.5);
843
+ z-index: 49;
844
+ -webkit-tap-highlight-color: transparent;
845
+ }
846
+
847
+ .sidebar-close-btn { display: none; }
848
+ .hamburger-btn { display: none; }
849
+ .mobile-only { display: none !important; }
850
+ .mobile-tabs { display: none; }
851
+
852
+ /* ── Mobile Breakpoint ─────── */
853
+ @media (max-width: 768px) {
854
+ :root {
855
+ --sidebar-w: 280px;
856
+ --topbar-h: 48px;
857
+ --panel-header-h: 44px;
858
+ --mobile-tab-h: 56px;
859
+ }
860
+
861
+ .desktop-only { display: none !important; }
862
+ .mobile-only { display: flex !important; }
863
+
864
+ /* Sidebar → slide-out drawer */
865
+ #sidebar {
866
+ position: fixed;
867
+ left: 0; top: 0; bottom: 0;
868
+ width: var(--sidebar-w);
869
+ transform: translateX(-100%);
870
+ transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
871
+ z-index: 50;
872
+ box-shadow: none;
873
+ }
874
+
875
+ #sidebar.open {
876
+ transform: translateX(0);
877
+ box-shadow: var(--shadow-lg);
878
+ }
879
+
880
+ .sidebar-backdrop.open {
881
+ display: block;
882
+ }
883
+
884
+ .sidebar-close-btn {
885
+ display: inline-flex;
886
+ margin-left: auto;
887
+ }
888
+
889
+ .hamburger-btn { display: inline-flex; }
890
+
891
+ /* Sidebar brand spacing */
892
+ .sidebar-brand { padding: 12px 14px 10px; }
893
+ .brand-text { font-size: 14px; }
894
+
895
+ /* Sidebar items bigger touch targets */
896
+ .zone-list li {
897
+ padding: 10px 12px;
898
+ font-size: 14px;
899
+ min-height: 44px;
900
+ }
901
+
902
+ .zone-list li .zone-icon { width: 20px; height: 20px; }
903
+
904
+ /* Main takes full width */
905
+ #main { width: 100%; }
906
+
907
+ /* Topbar mobile */
908
+ .topbar {
909
+ height: var(--topbar-h);
910
+ padding: 0 8px;
911
+ gap: 4px;
912
+ }
913
+
914
+ .topbar-left { gap: 6px; }
915
+
916
+ .zone-indicator {
917
+ font-size: 11px;
918
+ padding: 3px 8px;
919
+ max-width: 120px;
920
+ overflow: hidden;
921
+ }
922
+
923
+ .zone-indicator #zone-title {
924
+ overflow: hidden;
925
+ text-overflow: ellipsis;
926
+ white-space: nowrap;
927
+ }
928
+
929
+ /* Port button compact */
930
+ .btn-toggle-ports span:not(.port-count) { display: none; }
931
+ #btn-toggle-ports { padding: 4px 8px; }
932
+
933
+ /* Mobile breadcrumb (inside files panel) */
934
+ .mobile-breadcrumb {
935
+ display: flex !important;
936
+ padding: 6px 12px;
937
+ border-bottom: 1px solid var(--border);
938
+ background: var(--bg-1);
939
+ overflow-x: auto;
940
+ -webkit-overflow-scrolling: touch;
941
+ flex-shrink: 0;
942
+ }
943
+
944
+ .mobile-breadcrumb span {
945
+ font-size: 13px;
946
+ padding: 4px 8px;
947
+ min-height: 32px;
948
+ display: flex;
949
+ align-items: center;
950
+ }
951
+
952
+ /* Workspace body → stacked full-screen */
953
+ .workspace-body {
954
+ flex-direction: column;
955
+ position: relative;
956
+ }
957
+
958
+ /* Hide resizers on mobile */
959
+ .resizer-v, .resizer-h { display: none; }
960
+
961
+ /* Each panel = absolute full screen, switch via class */
962
+ .panel-files {
963
+ width: 100% !important;
964
+ max-width: 100%;
965
+ min-width: 100%;
966
+ border-right: none;
967
+ position: absolute;
968
+ inset: 0;
969
+ z-index: 1;
970
+ }
971
+
972
+ .panel-right {
973
+ position: absolute;
974
+ inset: 0;
975
+ z-index: 1;
976
+ }
977
+
978
+ .pane-editor {
979
+ position: absolute;
980
+ inset: 0;
981
+ z-index: 1;
982
+ }
983
+
984
+ .pane-terminal {
985
+ position: absolute;
986
+ inset: 0;
987
+ height: 100% !important;
988
+ z-index: 1;
989
+ }
990
+
991
+ /* Only show active mobile panel */
992
+ .panel-files,
993
+ .pane-editor,
994
+ .pane-terminal {
995
+ display: none;
996
+ }
997
+
998
+ .panel-files.m-active { display: flex; }
999
+ .pane-editor.m-active { display: flex; }
1000
+ .pane-terminal.m-active { display: flex; }
1001
+
1002
+ /* When editor or terminal is active, show panel-right as container */
1003
+ .panel-right.m-active { display: flex; }
1004
+
1005
+ /* File items — bigger touch targets, always show actions */
1006
+ .file-item {
1007
+ min-height: 48px;
1008
+ height: auto;
1009
+ padding: 8px 12px;
1010
+ gap: 10px;
1011
+ }
1012
+
1013
+ .file-item .fi-icon { width: 20px; height: 20px; }
1014
+ .file-item .fi-icon svg { width: 18px; height: 18px; }
1015
+ .file-item .fi-name { font-size: 14px; }
1016
+ .file-item .fi-size { font-size: 12px; }
1017
+
1018
+ /* Always show file actions on touch */
1019
+ .file-item .fi-actions {
1020
+ opacity: 1;
1021
+ }
1022
+
1023
+ .fi-actions button {
1024
+ width: 34px; height: 34px;
1025
+ }
1026
+
1027
+ .fi-actions button svg { width: 16px; height: 16px; }
1028
+
1029
+ /* Panel headers bigger */
1030
+ .panel-header, .pane-header {
1031
+ height: var(--panel-header-h);
1032
+ padding: 0 12px;
1033
+ }
1034
+
1035
+ .panel-title { font-size: 12px; }
1036
+ .panel-title svg { width: 15px; height: 15px; }
1037
+
1038
+ .icon-btn-sm {
1039
+ width: 36px; height: 36px;
1040
+ }
1041
+
1042
+ .icon-btn-sm svg { width: 18px; height: 18px; }
1043
+
1044
+ /* Editor mobile */
1045
+ .editor-container .CodeMirror {
1046
+ font-size: 12px;
1047
+ }
1048
+
1049
+ .pane-tab {
1050
+ padding: 6px 12px;
1051
+ font-size: 13px;
1052
+ }
1053
+
1054
+ /* Terminal mobile */
1055
+ .terminal-container .xterm {
1056
+ padding: 2px 0 2px 2px;
1057
+ }
1058
+
1059
+ /* Bottom Tab Bar */
1060
+ .mobile-tabs {
1061
+ display: flex;
1062
+ height: var(--mobile-tab-h);
1063
+ background: var(--bg-1);
1064
+ border-top: 1px solid var(--border);
1065
+ flex-shrink: 0;
1066
+ z-index: 10;
1067
+ }
1068
+
1069
+ .mobile-tab {
1070
+ flex: 1;
1071
+ display: flex;
1072
+ flex-direction: column;
1073
+ align-items: center;
1074
+ justify-content: center;
1075
+ gap: 2px;
1076
+ background: none;
1077
+ border: none;
1078
+ color: var(--text-3);
1079
+ cursor: pointer;
1080
+ font-size: 10px;
1081
+ font-weight: 500;
1082
+ font-family: var(--font);
1083
+ padding: 6px 0;
1084
+ -webkit-tap-highlight-color: transparent;
1085
+ transition: color var(--transition);
1086
+ position: relative;
1087
+ }
1088
+
1089
+ .mobile-tab svg { width: 20px; height: 20px; }
1090
+
1091
+ .mobile-tab.active {
1092
+ color: var(--accent);
1093
+ }
1094
+
1095
+ .mobile-tab.active::after {
1096
+ content: '';
1097
+ position: absolute;
1098
+ top: 0;
1099
+ left: 25%; right: 25%;
1100
+ height: 2px;
1101
+ background: var(--accent);
1102
+ border-radius: 0 0 2px 2px;
1103
+ }
1104
+
1105
+ .mobile-tab.has-dot::before {
1106
+ content: '';
1107
+ position: absolute;
1108
+ top: 6px;
1109
+ right: calc(50% - 16px);
1110
+ width: 6px; height: 6px;
1111
+ background: var(--accent);
1112
+ border-radius: 50%;
1113
+ }
1114
+
1115
+ /* Modals full-width on mobile */
1116
+ .modal {
1117
+ width: 100%;
1118
+ max-width: 100vw;
1119
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1120
+ margin: 0;
1121
+ position: fixed;
1122
+ bottom: 0;
1123
+ left: 0;
1124
+ right: 0;
1125
+ animation: slideUpModal 250ms ease;
1126
+ }
1127
+
1128
+ .modal-sm { width: 100%; }
1129
+
1130
+ .modal form { padding: 16px; }
1131
+
1132
+ .form-group input[type="text"],
1133
+ .form-group input[type="number"] {
1134
+ padding: 12px 14px;
1135
+ font-size: 16px; /* prevents iOS zoom */
1136
+ }
1137
+
1138
+ .modal-overlay {
1139
+ align-items: flex-end;
1140
+ }
1141
+
1142
+ @keyframes slideUpModal {
1143
+ from { transform: translateY(100%); }
1144
+ to { transform: translateY(0); }
1145
+ }
1146
+
1147
+ /* Port panel on mobile */
1148
+ .port-panel {
1149
+ position: fixed;
1150
+ top: auto !important;
1151
+ bottom: var(--mobile-tab-h);
1152
+ left: 8px;
1153
+ right: 8px;
1154
+ width: auto;
1155
+ border-radius: var(--radius-lg);
1156
+ }
1157
+
1158
+ /* Toast at top on mobile (avoid keyboard) */
1159
+ .toast-container {
1160
+ top: 12px;
1161
+ bottom: auto;
1162
+ left: 12px;
1163
+ right: 12px;
1164
+ }
1165
+
1166
+ .toast { max-width: 100%; }
1167
+
1168
+ /* Welcome page mobile */
1169
+ .welcome-hero { padding: 20px; }
1170
+ .welcome-hero h1 { font-size: 22px; }
1171
+ .welcome-hero p { font-size: 13px; max-width: 280px; }
1172
+ .welcome-icon { width: 64px; height: 64px; }
1173
+ .welcome-icon svg { width: 28px; height: 28px; }
1174
+ .welcome-glow { width: 200px; height: 200px; }
1175
+
1176
+ /* Empty state mobile */
1177
+ .empty-state { padding: 30px 16px; }
1178
+ .editor-empty svg { width: 24px; height: 24px; }
1179
+
1180
+ /* Icon buttons bigger for touch */
1181
+ .icon-btn {
1182
+ width: 40px; height: 40px;
1183
+ }
1184
+
1185
+ .icon-btn svg { width: 20px; height: 20px; }
1186
+
1187
+ /* Environment badges in sidebar */
1188
+ .sidebar-bottom { padding: 10px 14px; }
1189
+ .badge { font-size: 9px; padding: 3px 8px; }
1190
+ }
1191
+
1192
+ /* ── Small phones (< 380px) ── */
1193
+ @media (max-width: 380px) {
1194
+ :root {
1195
+ --sidebar-w: 260px;
1196
+ }
1197
+
1198
+ .zone-indicator { max-width: 90px; font-size: 10px; }
1199
+ .mobile-tab span { font-size: 9px; }
1200
+ .mobile-tab svg { width: 18px; height: 18px; }
1201
+ }
1202
+
1203
+ /* ── Form input number (port) ── */
1204
+ .form-group input[type="number"] {
1205
+ width: 100%;
1206
+ padding: 8px 12px;
1207
+ background: var(--bg-0);
1208
+ border: 1px solid var(--border);
1209
+ border-radius: var(--radius);
1210
+ color: var(--text);
1211
+ font-size: 13px;
1212
+ outline: none;
1213
+ transition: border-color var(--transition);
1214
+ font-family: var(--font);
1215
+ }
1216
+
1217
+ .form-group input[type="number"]:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-glow); }
1218
+
1219
+ /* ── Touch optimizations ───── */
1220
+ @media (pointer: coarse) {
1221
+ .file-item .fi-actions { opacity: 1; }
1222
+
1223
+ .file-item {
1224
+ min-height: 48px;
1225
+ -webkit-tap-highlight-color: transparent;
1226
+ }
1227
+
1228
+ .zone-list li {
1229
+ min-height: 44px;
1230
+ -webkit-tap-highlight-color: transparent;
1231
+ }
1232
+
1233
+ .icon-btn-sm {
1234
+ min-width: 36px;
1235
+ min-height: 36px;
1236
+ }
1237
+ }
1238
+
1239
+ /* Safe area for notch devices */
1240
+ @supports (padding: env(safe-area-inset-bottom)) {
1241
+ .mobile-tabs {
1242
+ padding-bottom: env(safe-area-inset-bottom);
1243
+ }
1244
+
1245
+ .modal {
1246
+ padding-bottom: env(safe-area-inset-bottom);
1247
+ }
1248
+ }
1249
+
1250
  /* ── Utility ───────────────── */
1251
  .hidden { display: none !important; }
storage.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared storage layer — zone metadata and path utilities.
3
+
4
+ Single Responsibility: only handles metadata persistence and path resolution.
5
+ Dependency Inversion: routers depend on these abstractions instead of importing each other.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from config import DATA_DIR, ZONES_META, ZONE_NAME_PATTERN
13
+
14
+
15
+ def load_meta() -> dict:
16
+ """Load zones metadata from JSON file."""
17
+ if ZONES_META.exists():
18
+ return json.loads(ZONES_META.read_text(encoding="utf-8"))
19
+ return {}
20
+
21
+
22
+ def save_meta(meta: dict):
23
+ """Save zones metadata to JSON file."""
24
+ ZONES_META.write_text(json.dumps(meta, indent=2, default=str), encoding="utf-8")
25
+
26
+
27
+ def validate_zone_name(name: str):
28
+ """Validate zone name format. Raises ValueError if invalid."""
29
+ if not ZONE_NAME_PATTERN.match(name):
30
+ raise ValueError("Tên zone chỉ chứa a-z, A-Z, 0-9, _, - (tối đa 50 ký tự)")
31
+
32
+
33
+ def get_zone_path(name: str) -> Path:
34
+ """Get the filesystem path for a zone, validating it exists."""
35
+ validate_zone_name(name)
36
+ zone_path = DATA_DIR / name
37
+ if not zone_path.is_dir():
38
+ raise ValueError(f"Zone '{name}' không tồn tại")
39
+ return zone_path
40
+
41
+
42
+ def safe_path(zone_path: Path, rel_path: str) -> Path:
43
+ """Resolve a relative path within a zone, preventing path traversal."""
44
+ target = (zone_path / rel_path).resolve()
45
+ zone_resolved = zone_path.resolve()
46
+ if target != zone_resolved and not str(target).startswith(str(zone_resolved) + os.sep):
47
+ raise ValueError("Truy cập ngoài zone không được phép")
48
+ return target