kokokoasd commited on
Commit
36ce73b
·
verified ·
1 Parent(s): 1f015ef

Upload 21 files

Browse files
Files changed (8) hide show
  1. auth.py +110 -0
  2. routers/files.py +24 -10
  3. routers/ports.py +9 -5
  4. routers/proxy.py +12 -3
  5. routers/terminal.py +15 -1
  6. routers/zones.py +18 -8
  7. static/app.js +20 -8
  8. storage.py +12 -0
auth.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auth dependencies for the Space backend.
3
+
4
+ Verifies JWT tokens by calling the Admin Worker's /auth/me endpoint.
5
+ Results are cached in-memory (TTL-based) to avoid hitting the Worker on every request.
6
+ No JWT_SECRET needed on the Space side.
7
+ """
8
+
9
+ import time
10
+ from dataclasses import dataclass
11
+
12
+ import httpx
13
+ from fastapi import Request, WebSocket, HTTPException
14
+
15
+ from config import ADMIN_API_URL
16
+
17
+
18
+ @dataclass
19
+ class AuthUser:
20
+ """Authenticated user extracted from JWT."""
21
+ sub: str # user ID
22
+ username: str
23
+ role: str # "admin" or "user"
24
+
25
+
26
+ # ── Token verification cache ──
27
+ # Maps token -> (AuthUser, expiry_timestamp)
28
+ _token_cache: dict[str, tuple[AuthUser, float]] = {}
29
+ _CACHE_TTL = 300 # 5 minutes
30
+
31
+
32
+ def _cleanup_cache():
33
+ """Remove expired entries from cache."""
34
+ now = time.time()
35
+ expired = [k for k, (_, exp) in _token_cache.items() if exp < now]
36
+ for k in expired:
37
+ del _token_cache[k]
38
+
39
+
40
+ def verify_token(token: str) -> AuthUser | None:
41
+ """Verify a token by calling Worker /auth/me, with caching."""
42
+ if not token or not ADMIN_API_URL:
43
+ return None
44
+
45
+ # Check cache first
46
+ now = time.time()
47
+ cached = _token_cache.get(token)
48
+ if cached:
49
+ user, expiry = cached
50
+ if expiry > now:
51
+ return user
52
+ else:
53
+ del _token_cache[token]
54
+
55
+ # Call Worker to verify
56
+ try:
57
+ resp = httpx.get(
58
+ f"{ADMIN_API_URL}/auth/me",
59
+ headers={"Authorization": f"Bearer {token}"},
60
+ timeout=10,
61
+ )
62
+ if resp.status_code != 200:
63
+ return None
64
+
65
+ data = resp.json()
66
+ user_data = data.get("user")
67
+ if not user_data:
68
+ return None
69
+
70
+ user = AuthUser(
71
+ sub=user_data.get("id", ""),
72
+ username=user_data.get("username", ""),
73
+ role=user_data.get("role", "user"),
74
+ )
75
+
76
+ if not user.sub or not user.username:
77
+ return None
78
+
79
+ # Cache the result
80
+ _token_cache[token] = (user, now + _CACHE_TTL)
81
+
82
+ # Periodic cleanup
83
+ if len(_token_cache) > 100:
84
+ _cleanup_cache()
85
+
86
+ return user
87
+
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def get_current_user(request: Request) -> AuthUser:
93
+ """FastAPI dependency: extract and verify JWT from Authorization header."""
94
+ auth_header = request.headers.get("Authorization", "")
95
+ if not auth_header.startswith("Bearer "):
96
+ raise HTTPException(401, "Chưa đăng nhập")
97
+
98
+ token = auth_header[7:]
99
+ user = verify_token(token)
100
+ if not user:
101
+ raise HTTPException(401, "Token không hợp lệ hoặc đã hết hạn")
102
+ return user
103
+
104
+
105
+ def get_ws_user(websocket: WebSocket) -> AuthUser | None:
106
+ """Extract and verify JWT from WebSocket query parameter ?token=..."""
107
+ token = websocket.query_params.get("token", "")
108
+ if not token:
109
+ return None
110
+ return verify_token(token)
routers/files.py CHANGED
@@ -10,17 +10,24 @@ 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():
@@ -39,8 +46,9 @@ def list_files(zone_name: str, path: str = Query("")):
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():
@@ -51,8 +59,9 @@ def read_file(zone_name: str, path: str = Query(...)):
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():
@@ -63,8 +72,9 @@ def download_file(zone_name: str, path: str = Query(...)):
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)
@@ -75,8 +85,9 @@ def write_file(zone_name: str, path: str = Form(...), content: str = Form(...)):
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)
@@ -86,8 +97,9 @@ def create_folder(zone_name: str, path: str = Form(...)):
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)
@@ -99,8 +111,9 @@ async def upload_file(zone_name: str, path: str = Form(""), file: UploadFile = F
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():
@@ -117,8 +130,9 @@ def delete_file(zone_name: str, path: str = Query(...)):
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():
 
10
  from datetime import datetime
11
  from pathlib import Path
12
 
13
+ from fastapi import APIRouter, Depends, Form, File, UploadFile, Query, HTTPException
14
  from fastapi.responses import FileResponse
15
 
16
+ from auth import AuthUser, get_current_user
17
+ from storage import get_zone_path, safe_path, check_zone_owner
18
 
19
  router = APIRouter(prefix="/api/zones/{zone_name}/files", tags=["files"])
20
 
21
 
22
+ def _check_access(zone_name: str, user: AuthUser):
23
+ """Validate zone access for the current user."""
24
+ check_zone_owner(zone_name, user.sub, user.role)
25
+
26
+
27
  @router.get("")
28
+ def list_files(zone_name: str, path: str = Query(""), user: AuthUser = Depends(get_current_user)):
29
  try:
30
+ _check_access(zone_name, user)
31
  zone_path = get_zone_path(zone_name)
32
  target = safe_path(zone_path, path)
33
  if not target.is_dir():
 
46
 
47
 
48
  @router.get("/read")
49
+ def read_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
50
  try:
51
+ _check_access(zone_name, user)
52
  zone_path = get_zone_path(zone_name)
53
  target = safe_path(zone_path, path)
54
  if not target.is_file():
 
59
 
60
 
61
  @router.get("/download")
62
+ def download_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
63
  try:
64
+ _check_access(zone_name, user)
65
  zone_path = get_zone_path(zone_name)
66
  target = safe_path(zone_path, path)
67
  if not target.is_file():
 
72
 
73
 
74
  @router.post("/write")
75
+ def write_file(zone_name: str, path: str = Form(...), content: str = Form(...), user: AuthUser = Depends(get_current_user)):
76
  try:
77
+ _check_access(zone_name, user)
78
  zone_path = get_zone_path(zone_name)
79
  target = safe_path(zone_path, path)
80
  target.parent.mkdir(parents=True, exist_ok=True)
 
85
 
86
 
87
  @router.post("/mkdir")
88
+ def create_folder(zone_name: str, path: str = Form(...), user: AuthUser = Depends(get_current_user)):
89
  try:
90
+ _check_access(zone_name, user)
91
  zone_path = get_zone_path(zone_name)
92
  target = safe_path(zone_path, path)
93
  target.mkdir(parents=True, exist_ok=True)
 
97
 
98
 
99
  @router.post("/upload")
100
+ async def upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...), user: AuthUser = Depends(get_current_user)):
101
  try:
102
+ _check_access(zone_name, user)
103
  zone_path = get_zone_path(zone_name)
104
  dest = safe_path(zone_path, os.path.join(path, file.filename))
105
  dest.parent.mkdir(parents=True, exist_ok=True)
 
111
 
112
 
113
  @router.delete("")
114
+ def delete_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
115
  try:
116
+ _check_access(zone_name, user)
117
  zone_path = get_zone_path(zone_name)
118
  target = safe_path(zone_path, path)
119
  if target == zone_path.resolve():
 
130
 
131
 
132
  @router.post("/rename")
133
+ def rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...), user: AuthUser = Depends(get_current_user)):
134
  try:
135
+ _check_access(zone_name, user)
136
  zone_path = get_zone_path(zone_name)
137
  source = safe_path(zone_path, old_path)
138
  if not source.exists():
routers/ports.py CHANGED
@@ -5,10 +5,11 @@ 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
 
@@ -24,8 +25,9 @@ def _validate_zone(meta: dict, zone_name: str):
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", [])
@@ -34,9 +36,10 @@ def list_ports(zone_name: str):
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
 
@@ -54,8 +57,9 @@ def add_port(zone_name: str, port: int = Form(...), label: str = Form("")):
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
 
 
5
  Reverse proxy logic is in proxy.py — separate reason to change.
6
  """
7
 
8
+ from fastapi import APIRouter, Depends, Form, HTTPException
9
 
10
+ from auth import AuthUser, get_current_user
11
  from config import MIN_PORT, MAX_PORT
12
+ from storage import load_meta, save_meta, check_zone_owner
13
 
14
  router = APIRouter(prefix="/api/zones/{zone_name}/ports", tags=["ports"])
15
 
 
25
 
26
 
27
  @router.get("")
28
+ def list_ports(zone_name: str, user: AuthUser = Depends(get_current_user)):
29
  try:
30
+ check_zone_owner(zone_name, user.sub, user.role)
31
  meta = load_meta()
32
  _validate_zone(meta, zone_name)
33
  return meta[zone_name].get("ports", [])
 
36
 
37
 
38
  @router.post("")
39
+ def add_port(zone_name: str, port: int = Form(...), label: str = Form(""), user: AuthUser = Depends(get_current_user)):
40
  try:
41
  _validate_port(port)
42
+ check_zone_owner(zone_name, user.sub, user.role)
43
  meta = load_meta()
44
  _validate_zone(meta, zone_name)
45
 
 
57
 
58
 
59
  @router.delete("/{port}")
60
+ def remove_port(zone_name: str, port: int, user: AuthUser = Depends(get_current_user)):
61
  try:
62
+ check_zone_owner(zone_name, user.sub, user.role)
63
  meta = load_meta()
64
  _validate_zone(meta, zone_name)
65
 
routers/proxy.py CHANGED
@@ -9,11 +9,12 @@ 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
 
@@ -56,9 +57,10 @@ def _validate_proxy_access(zone_name: str, port: int):
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
 
@@ -101,8 +103,15 @@ async def proxy_http(request: Request, zone_name: str, port: int, subpath: str =
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
 
9
  import json
10
 
11
  import httpx
12
+ from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
13
  from fastapi.responses import Response
14
 
15
+ from auth import AuthUser, get_current_user, get_ws_user
16
  from config import MIN_PORT, MAX_PORT
17
+ from storage import load_meta, check_zone_owner
18
 
19
  router = APIRouter(tags=["proxy"])
20
 
 
57
  "/port/{zone_name}/{port}/{subpath:path}",
58
  methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
59
  )
60
+ async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = "", user: AuthUser = Depends(get_current_user)):
61
  try:
62
  _validate_proxy_access(zone_name, port)
63
+ check_zone_owner(zone_name, user.sub, user.role)
64
  except ValueError:
65
  return Response(content="Port not mapped", status_code=404)
66
 
 
103
 
104
  @router.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
105
  async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
106
+ # Authenticate via query parameter
107
+ user = get_ws_user(websocket)
108
+ if not user:
109
+ await websocket.close(code=4001, reason="Chưa đăng nhập")
110
+ return
111
+
112
  try:
113
  _validate_proxy_access(zone_name, port)
114
+ check_zone_owner(zone_name, user.sub, user.role)
115
  except ValueError:
116
  await websocket.close(code=4004, reason="Port not mapped")
117
  return
routers/terminal.py CHANGED
@@ -18,7 +18,8 @@ import termios
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
 
@@ -132,6 +133,19 @@ def kill_terminal(zone_name: str):
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:
 
18
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
19
 
20
  from config import SCROLLBACK_SIZE
21
+ from storage import get_zone_path, check_zone_owner
22
+ from auth import get_ws_user
23
 
24
  router = APIRouter(tags=["terminal"])
25
 
 
133
 
134
  @router.websocket("/ws/terminal/{zone_name}")
135
  async def terminal_ws(websocket: WebSocket, zone_name: str):
136
+ # Authenticate via query parameter
137
+ user = get_ws_user(websocket)
138
+ if not user:
139
+ await websocket.close(code=4001, reason="Chưa đăng nhập")
140
+ return
141
+
142
+ # Check zone ownership
143
+ try:
144
+ check_zone_owner(zone_name, user.sub, user.role)
145
+ except ValueError as e:
146
+ await websocket.close(code=4003, reason=str(e))
147
+ return
148
+
149
  await websocket.accept()
150
 
151
  try:
routers/zones.py CHANGED
@@ -9,10 +9,11 @@ import shutil
9
  from datetime import datetime
10
 
11
  import httpx
12
- from fastapi import APIRouter, Form, HTTPException
13
 
 
14
  from config import DATA_DIR, ADMIN_API_URL
15
- from storage import load_meta, save_meta, validate_zone_name
16
  from routers.terminal import kill_terminal
17
 
18
  router = APIRouter(prefix="/api/zones", tags=["zones"])
@@ -32,7 +33,7 @@ def _get_max_zones() -> int:
32
 
33
 
34
  @router.get("")
35
- def list_zones():
36
  meta = load_meta()
37
  return [
38
  {
@@ -42,22 +43,24 @@ def list_zones():
42
  "exists": (DATA_DIR / name).is_dir(),
43
  }
44
  for name, info in meta.items()
 
45
  ]
46
 
47
 
48
  @router.post("")
49
- def create_zone(name: str = Form(...), description: str = Form("")):
50
  try:
51
  validate_zone_name(name)
52
  zone_path = DATA_DIR / name
53
  if zone_path.exists():
54
  raise ValueError(f"Zone '{name}' đã tồn tại")
55
 
56
- # Check zone limit
57
  max_zones = _get_max_zones()
58
  if max_zones > 0:
59
  meta = load_meta()
60
- if len(meta) >= max_zones:
 
61
  raise ValueError(f"Đã đạt giới hạn {max_zones} zones")
62
 
63
  zone_path.mkdir(parents=True)
@@ -66,7 +69,12 @@ def create_zone(name: str = Form(...), description: str = Form("")):
66
  )
67
 
68
  meta = load_meta()
69
- meta[name] = {"created": datetime.now().isoformat(), "description": description}
 
 
 
 
 
70
  save_meta(meta)
71
  return {"name": name, "path": str(zone_path)}
72
  except ValueError as e:
@@ -74,9 +82,11 @@ def create_zone(name: str = Form(...), description: str = Form("")):
74
 
75
 
76
  @router.delete("/{zone_name}")
77
- def delete_zone(zone_name: str):
78
  try:
79
  validate_zone_name(zone_name)
 
 
80
  zone_path = DATA_DIR / zone_name
81
  if not zone_path.exists():
82
  raise ValueError(f"Zone '{zone_name}' không tồn tại")
 
9
  from datetime import datetime
10
 
11
  import httpx
12
+ from fastapi import APIRouter, Depends, Form, HTTPException
13
 
14
+ from auth import AuthUser, get_current_user
15
  from config import DATA_DIR, ADMIN_API_URL
16
+ from storage import load_meta, save_meta, validate_zone_name, check_zone_owner
17
  from routers.terminal import kill_terminal
18
 
19
  router = APIRouter(prefix="/api/zones", tags=["zones"])
 
33
 
34
 
35
  @router.get("")
36
+ def list_zones(user: AuthUser = Depends(get_current_user)):
37
  meta = load_meta()
38
  return [
39
  {
 
43
  "exists": (DATA_DIR / name).is_dir(),
44
  }
45
  for name, info in meta.items()
46
+ if user.role == "admin" or info.get("owner_id") == user.sub
47
  ]
48
 
49
 
50
  @router.post("")
51
+ def create_zone(name: str = Form(...), description: str = Form(""), user: AuthUser = Depends(get_current_user)):
52
  try:
53
  validate_zone_name(name)
54
  zone_path = DATA_DIR / name
55
  if zone_path.exists():
56
  raise ValueError(f"Zone '{name}' đã tồn tại")
57
 
58
+ # Check zone limit (per-user for non-admin)
59
  max_zones = _get_max_zones()
60
  if max_zones > 0:
61
  meta = load_meta()
62
+ user_zones = [n for n, info in meta.items() if info.get("owner_id") == user.sub]
63
+ if len(user_zones) >= max_zones:
64
  raise ValueError(f"Đã đạt giới hạn {max_zones} zones")
65
 
66
  zone_path.mkdir(parents=True)
 
69
  )
70
 
71
  meta = load_meta()
72
+ meta[name] = {
73
+ "created": datetime.now().isoformat(),
74
+ "description": description,
75
+ "owner_id": user.sub,
76
+ "owner_name": user.username,
77
+ }
78
  save_meta(meta)
79
  return {"name": name, "path": str(zone_path)}
80
  except ValueError as e:
 
82
 
83
 
84
  @router.delete("/{zone_name}")
85
+ def delete_zone(zone_name: str, user: AuthUser = Depends(get_current_user)):
86
  try:
87
  validate_zone_name(zone_name)
88
+ check_zone_owner(zone_name, user.sub, user.role)
89
+
90
  zone_path = DATA_DIR / zone_name
91
  if not zone_path.exists():
92
  raise ValueError(f"Zone '{zone_name}' không tồn tại")
static/app.js CHANGED
@@ -178,8 +178,8 @@ function hugpanel() {
178
  async api(url, options = {}) {
179
  try {
180
  const headers = options.headers || {};
181
- // Add JWT token for backup API calls (proxied to Worker)
182
- if (this.token && url.startsWith('/api/backup')) {
183
  headers['Authorization'] = `Bearer ${this.token}`;
184
  }
185
  const resp = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
@@ -451,11 +451,23 @@ function hugpanel() {
451
  } catch {}
452
  },
453
 
454
- downloadFile(path, name) {
455
- const a = document.createElement('a');
456
- a.href = `/api/zones/${this.currentZone}/files/download?path=${encodeURIComponent(path)}`;
457
- a.download = name;
458
- a.click();
 
 
 
 
 
 
 
 
 
 
 
 
459
  },
460
 
461
  startRename(file) {
@@ -543,7 +555,7 @@ function hugpanel() {
543
 
544
  // WebSocket
545
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
546
- const wsUrl = `${proto}//${location.host}/ws/terminal/${this.currentZone}`;
547
  this.termWs = new WebSocket(wsUrl);
548
  this.termWs.binaryType = 'arraybuffer';
549
 
 
178
  async api(url, options = {}) {
179
  try {
180
  const headers = options.headers || {};
181
+ // Add JWT token to all API calls
182
+ if (this.token) {
183
  headers['Authorization'] = `Bearer ${this.token}`;
184
  }
185
  const resp = await fetch(url, { ...options, headers: { ...headers, ...options.headers } });
 
451
  } catch {}
452
  },
453
 
454
+ async downloadFile(path, name) {
455
+ try {
456
+ const resp = await fetch(
457
+ `/api/zones/${this.currentZone}/files/download?path=${encodeURIComponent(path)}`,
458
+ { headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {} }
459
+ );
460
+ if (!resp.ok) throw new Error('Download failed');
461
+ const blob = await resp.blob();
462
+ const url = URL.createObjectURL(blob);
463
+ const a = document.createElement('a');
464
+ a.href = url;
465
+ a.download = name;
466
+ a.click();
467
+ URL.revokeObjectURL(url);
468
+ } catch (err) {
469
+ this.notify(err.message, 'error');
470
+ }
471
  },
472
 
473
  startRename(file) {
 
555
 
556
  // WebSocket
557
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
558
+ const wsUrl = `${proto}//${location.host}/ws/terminal/${this.currentZone}?token=${encodeURIComponent(this.token || '')}`;
559
  this.termWs = new WebSocket(wsUrl);
560
  this.termWs.binaryType = 'arraybuffer';
561
 
storage.py CHANGED
@@ -46,3 +46,15 @@ def safe_path(zone_path: Path, rel_path: str) -> Path:
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
 
 
 
 
 
 
 
 
 
 
 
 
 
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
49
+
50
+
51
+ def check_zone_owner(zone_name: str, user_sub: str, user_role: str):
52
+ """Check that the user owns the zone. Admins can access all zones."""
53
+ if user_role == "admin":
54
+ return
55
+ meta = load_meta()
56
+ info = meta.get(zone_name)
57
+ if not info:
58
+ raise ValueError(f"Zone '{zone_name}' không tồn tại")
59
+ if info.get("owner_id") != user_sub:
60
+ raise ValueError("Bạn không có quyền truy cập zone này")