marriedtermiteblyi commited on
Commit
51d57fc
·
verified ·
1 Parent(s): 3a4b37f

Upload 6 files

Browse files
Files changed (5) hide show
  1. routers/backup.py +268 -167
  2. routers/files.py +157 -144
  3. routers/ports.py +85 -74
  4. routers/proxy.py +168 -159
  5. routers/zones.py +148 -102
routers/backup.py CHANGED
@@ -1,33 +1,53 @@
1
- """
2
  Backup & Restore API — talks directly to HuggingFace Dataset API.
3
 
4
  Flow:
5
  1. Space asks Worker for HF credentials (token, repo, user path prefix)
6
  2. Space uploads/downloads/lists archives directly via HuggingFace API
7
-
8
- Requires env var:
9
- ADMIN_API_URL - URL of the Cloudflare Worker admin API
10
  """
11
 
12
  import os
13
  import shutil
14
  import tarfile
 
15
  from datetime import datetime
16
  from pathlib import Path
17
 
18
  import httpx
19
- from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
20
 
21
- from config import DATA_DIR, ADMIN_API_URL, BACKUP_DIR
22
- from storage import load_meta, save_meta, validate_zone_name
 
 
23
 
24
  router = APIRouter(prefix="/api/backup", tags=["backup"])
25
 
26
  HF_API = "https://huggingface.co/api"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def _get_token(request: Request) -> str:
30
- """Extract JWT token from request Authorization header."""
31
  auth = request.headers.get("Authorization", "")
32
  if auth.startswith("Bearer "):
33
  return auth[7:]
@@ -35,7 +55,6 @@ def _get_token(request: Request) -> str:
35
 
36
 
37
  def _get_credentials(token: str) -> dict:
38
- """Get HF credentials from Worker. Returns {hf_token, repo, path_prefix}."""
39
  with httpx.Client(timeout=15) as client:
40
  resp = client.get(
41
  f"{ADMIN_API_URL}/backup/credentials",
@@ -48,7 +67,6 @@ def _get_credentials(token: str) -> dict:
48
 
49
 
50
  def _log_action(token: str, zone_name: str, action: str, status: str, file_path: str = ""):
51
- """Log backup/restore action to Worker (best-effort)."""
52
  try:
53
  with httpx.Client(timeout=10) as client:
54
  client.post(
@@ -60,19 +78,122 @@ def _log_action(token: str, zone_name: str, action: str, status: str, file_path:
60
  pass
61
 
62
 
63
- def _create_zone_archive(zone_name: str) -> Path:
64
- """Create a tar.gz archive of a zone directory."""
65
  zone_path = DATA_DIR / zone_name
66
  if not zone_path.is_dir():
67
  raise ValueError(f"Zone '{zone_name}' khong ton tai")
68
 
69
- archive_path = BACKUP_DIR / f"{zone_name}.tar.gz"
 
 
70
  with tarfile.open(archive_path, "w:gz") as tar:
71
  tar.add(str(zone_path), arcname=zone_name)
72
- return archive_path
73
 
74
 
75
- _backup_status: dict = {"running": False, "last": None, "error": None, "progress": ""}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
 
78
  @router.get("/status")
@@ -88,74 +209,76 @@ def backup_status():
88
 
89
 
90
  @router.get("/list")
91
- async def list_backups(request: Request):
92
  if not ADMIN_API_URL:
93
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
 
94
  token = _get_token(request)
95
  if not token:
96
  raise HTTPException(401, "Chua dang nhap")
 
97
  try:
98
  creds = _get_credentials(token)
99
- async with httpx.AsyncClient(timeout=30) as client:
100
- resp = await client.get(
101
- f"{HF_API}/datasets/{creds['repo']}/tree/main/{creds['path_prefix']}",
102
- headers={"Authorization": f"Bearer {creds['hf_token']}"},
103
- )
104
- if resp.status_code == 404:
105
- return []
106
- if resp.status_code != 200:
107
- raise HTTPException(502, f"HF API error: {resp.status_code} {resp.text}")
108
- tree = resp.json()
109
- meta = load_meta()
110
- return [
111
- {
112
- "zone_name": f["path"].split("/")[-1].replace(".tar.gz", ""),
113
- "file": f["path"],
114
- "size": (f.get("lfs") or {}).get("size") or f.get("size", 0),
115
- "last_modified": (f.get("lastCommit") or {}).get("date", ""),
116
- "local_exists": f["path"].split("/")[-1].replace(".tar.gz", "") in meta,
117
- }
118
- for f in tree
119
- if f.get("type") == "file" and f["path"].endswith(".tar.gz")
120
- ]
121
  except ValueError as e:
122
  raise HTTPException(502, str(e))
123
  except httpx.HTTPError as e:
124
  raise HTTPException(502, f"Khong the ket noi: {e}")
125
 
126
 
127
- def _upload_to_hf(creds: dict, zone_name: str, archive_path: Path):
128
- """Upload archive directly to HuggingFace Dataset via huggingface_hub."""
129
- from huggingface_hub import HfApi
 
 
 
 
 
130
 
131
- file_path = f"{creds['path_prefix']}/{zone_name}.tar.gz"
132
- api = HfApi(token=creds["hf_token"])
133
- api.upload_file(
134
- path_or_fileobj=str(archive_path),
135
- path_in_repo=file_path,
136
- repo_id=creds["repo"],
137
- repo_type="dataset",
138
- commit_message=f"Backup zone: {zone_name}",
139
- )
 
 
140
 
141
 
142
  @router.post("/zone/{zone_name}")
143
- async def backup_zone(zone_name: str, request: Request, background_tasks: BackgroundTasks):
 
 
 
 
 
144
  if not ADMIN_API_URL:
145
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
 
146
  token = _get_token(request)
147
  if not token:
148
  raise HTTPException(401, "Chua dang nhap")
 
149
  try:
150
  validate_zone_name(zone_name)
 
151
  if not (DATA_DIR / zone_name).is_dir():
152
  raise ValueError(f"Zone '{zone_name}' khong ton tai")
153
  except ValueError as e:
154
  raise HTTPException(400, str(e))
 
155
  if _backup_status["running"]:
156
  raise HTTPException(409, "Dang co backup khac dang chay")
157
 
158
- # Fetch credentials before background task (validates JWT now)
159
  try:
160
  creds = _get_credentials(token)
161
  except ValueError as e:
@@ -165,21 +288,21 @@ async def backup_zone(zone_name: str, request: Request, background_tasks: Backgr
165
  _backup_status["running"] = True
166
  _backup_status["error"] = None
167
  _backup_status["progress"] = f"Dang backup zone: {zone_name}..."
 
 
168
  try:
169
- archive_path = _create_zone_archive(zone_name)
170
- try:
171
- _upload_to_hf(creds, zone_name, archive_path)
172
- finally:
173
- archive_path.unlink(missing_ok=True)
174
- _log_action(token, zone_name, "backup", "success",
175
- f"{creds['path_prefix']}/{zone_name}.tar.gz")
176
  _backup_status["last"] = datetime.now().isoformat()
177
  _backup_status["progress"] = f"Backup zone {zone_name} thanh cong"
178
  except Exception as e:
179
  _backup_status["error"] = str(e)
180
  _backup_status["progress"] = f"Loi backup: {e}"
181
- _log_action(token, zone_name, "backup", "error")
182
  finally:
 
 
183
  _backup_status["running"] = False
184
 
185
  background_tasks.add_task(_run)
@@ -187,9 +310,14 @@ async def backup_zone(zone_name: str, request: Request, background_tasks: Backgr
187
 
188
 
189
  @router.post("/all")
190
- async def backup_all(request: Request, background_tasks: BackgroundTasks):
 
 
 
 
191
  if not ADMIN_API_URL:
192
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
 
193
  token = _get_token(request)
194
  if not token:
195
  raise HTTPException(401, "Chua dang nhap")
@@ -201,27 +329,26 @@ async def backup_all(request: Request, background_tasks: BackgroundTasks):
201
  except ValueError as e:
202
  raise HTTPException(502, str(e))
203
 
 
 
 
 
 
 
204
  def _run():
205
  _backup_status["running"] = True
206
  _backup_status["error"] = None
207
  _backup_status["progress"] = "Dang backup tat ca zones..."
208
  try:
209
- meta = load_meta()
210
- total = len(meta)
211
- done = 0
212
- for zone_name in meta:
213
- zone_path = DATA_DIR / zone_name
214
- if not zone_path.is_dir():
215
- continue
216
- _backup_status["progress"] = f"Dang backup zone {zone_name} ({done + 1}/{total})..."
217
- archive_path = _create_zone_archive(zone_name)
218
  try:
219
- _upload_to_hf(creds, zone_name, archive_path)
220
  finally:
221
  archive_path.unlink(missing_ok=True)
222
- _log_action(token, zone_name, "backup", "success",
223
- f"{creds['path_prefix']}/{zone_name}.tar.gz")
224
- done += 1
225
  _backup_status["last"] = datetime.now().isoformat()
226
  _backup_status["progress"] = "Backup tat ca zones thanh cong"
227
  except Exception as e:
@@ -234,37 +361,38 @@ async def backup_all(request: Request, background_tasks: BackgroundTasks):
234
  return {"ok": True, "message": "Dang backup tat ca zones trong nen..."}
235
 
236
 
237
- def _download_from_hf(creds: dict, zone_name: str) -> bytes:
238
- """Download archive directly from HuggingFace Dataset."""
239
- file_path = f"{creds['path_prefix']}/{zone_name}.tar.gz"
240
- with httpx.Client(timeout=300, follow_redirects=True) as client:
241
- resp = client.get(
242
- f"https://huggingface.co/datasets/{creds['repo']}/resolve/main/{file_path}",
243
- headers={"Authorization": f"Bearer {creds['hf_token']}"},
244
- )
245
- if resp.status_code == 404:
246
- raise FileNotFoundError(f"Backup zone '{zone_name}' khong ton tai")
247
- if resp.status_code != 200:
248
- raise ValueError(f"HF download error: {resp.status_code}")
249
- return resp.content
250
-
251
-
252
  @router.post("/restore/{zone_name}")
253
- async def restore_zone(zone_name: str, request: Request, background_tasks: BackgroundTasks):
 
 
 
 
 
 
254
  if not ADMIN_API_URL:
255
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
 
256
  token = _get_token(request)
257
  if not token:
258
  raise HTTPException(401, "Chua dang nhap")
 
259
  try:
260
  validate_zone_name(zone_name)
 
261
  except ValueError as e:
262
  raise HTTPException(400, str(e))
 
263
  if _backup_status["running"]:
264
  raise HTTPException(409, "Dang co backup/restore khac dang chay")
265
 
266
  try:
267
  creds = _get_credentials(token)
 
 
 
 
 
 
268
  except ValueError as e:
269
  raise HTTPException(502, str(e))
270
 
@@ -272,40 +400,26 @@ async def restore_zone(zone_name: str, request: Request, background_tasks: Backg
272
  _backup_status["running"] = True
273
  _backup_status["error"] = None
274
  _backup_status["progress"] = f"Dang restore zone: {zone_name}..."
 
275
  try:
276
- data = _download_from_hf(creds, zone_name)
277
- archive_path = BACKUP_DIR / f"{zone_name}.tar.gz"
 
 
278
  archive_path.write_bytes(data)
279
-
280
- try:
281
- zone_path = DATA_DIR / zone_name
282
- if zone_path.exists():
283
- shutil.rmtree(zone_path)
284
- zone_path.mkdir(parents=True, exist_ok=True)
285
- with tarfile.open(archive_path, "r:gz") as tar:
286
- for member in tar.getmembers():
287
- member_path = os.path.normpath(member.name)
288
- if member_path.startswith("..") or os.path.isabs(member_path):
289
- raise ValueError(f"Archive chua path khong an toan: {member.name}")
290
- if not member_path.startswith(zone_name):
291
- raise ValueError(f"Archive chua path ngoai zone: {member.name}")
292
- tar.extractall(path=str(DATA_DIR), filter="data")
293
- meta = load_meta()
294
- if zone_name not in meta:
295
- meta[zone_name] = {"description": f"Restored from backup", "created": datetime.now().isoformat()}
296
- save_meta(meta)
297
- finally:
298
- archive_path.unlink(missing_ok=True)
299
-
300
- _log_action(token, zone_name, "restore", "success",
301
- f"{creds['path_prefix']}/{zone_name}.tar.gz")
302
  _backup_status["last"] = datetime.now().isoformat()
303
  _backup_status["progress"] = f"Restore zone {zone_name} thanh cong"
304
  except Exception as e:
305
  _backup_status["error"] = str(e)
306
  _backup_status["progress"] = f"Loi restore: {e}"
307
- _log_action(token, zone_name, "restore", "error")
308
  finally:
 
 
309
  _backup_status["running"] = False
310
 
311
  background_tasks.add_task(_run)
@@ -313,9 +427,14 @@ async def restore_zone(zone_name: str, request: Request, background_tasks: Backg
313
 
314
 
315
  @router.post("/restore-all")
316
- async def restore_all(request: Request, background_tasks: BackgroundTasks):
 
 
 
 
317
  if not ADMIN_API_URL:
318
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
 
319
  token = _get_token(request)
320
  if not token:
321
  raise HTTPException(401, "Chua dang nhap")
@@ -327,64 +446,46 @@ async def restore_all(request: Request, background_tasks: BackgroundTasks):
327
  except ValueError as e:
328
  raise HTTPException(502, str(e))
329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  def _run():
331
  _backup_status["running"] = True
332
  _backup_status["error"] = None
333
  _backup_status["progress"] = "Dang restore tat ca zones..."
334
  try:
335
- # List backups from HF directly
336
- with httpx.Client(timeout=30) as client:
337
- resp = client.get(
338
- f"{HF_API}/datasets/{creds['repo']}/tree/main/{creds['path_prefix']}",
339
- headers={"Authorization": f"Bearer {creds['hf_token']}"},
340
- )
341
- if resp.status_code == 404:
342
- _backup_status["progress"] = "Khong co backup nao"
343
- return
344
- if resp.status_code != 200:
345
- raise ValueError(f"HF API error: {resp.status_code}")
346
-
347
- tree = resp.json()
348
- backup_files = [
349
- f for f in tree
350
- if f.get("type") == "file" and f["path"].endswith(".tar.gz")
351
- ]
352
- total = len(backup_files)
353
  done = 0
354
- for bf in backup_files:
355
- zn = bf["path"].split("/")[-1].replace(".tar.gz", "")
356
- _backup_status["progress"] = f"Dang restore zone {zn} ({done + 1}/{total})..."
357
- try:
358
- data = _download_from_hf(creds, zn)
359
- except FileNotFoundError:
360
- continue
361
- archive_path = BACKUP_DIR / f"{zn}.tar.gz"
362
- archive_path.write_bytes(data)
363
-
364
  try:
365
- zone_path = DATA_DIR / zn
366
- if zone_path.exists():
367
- shutil.rmtree(zone_path)
368
- zone_path = DATA_DIR / zn
369
- if zone_path.exists():
370
- shutil.rmtree(zone_path)
371
- zone_path.mkdir(parents=True, exist_ok=True)
372
- with tarfile.open(archive_path, "r:gz") as tar:
373
- for member in tar.getmembers():
374
- member_path = os.path.normpath(member.name)
375
- if member_path.startswith("..") or os.path.isabs(member_path):
376
- raise ValueError(f"Archive chua path khong an toan: {member.name}")
377
- if not member_path.startswith(zn):
378
- raise ValueError(f"Archive chua path ngoai zone: {member.name}")
379
- tar.extractall(path=str(DATA_DIR), filter="data")
380
- meta = load_meta()
381
- if zn not in meta:
382
- meta[zn] = {"description": "Restored from backup", "created": datetime.now().isoformat()}
383
- save_meta(meta)
384
  finally:
385
- archive_path.unlink(missing_ok=True)
386
- done += 1
387
-
388
  _backup_status["last"] = datetime.now().isoformat()
389
  _backup_status["progress"] = f"Restore {done}/{total} zones thanh cong"
390
  except Exception as e:
 
1
+ """
2
  Backup & Restore API — talks directly to HuggingFace Dataset API.
3
 
4
  Flow:
5
  1. Space asks Worker for HF credentials (token, repo, user path prefix)
6
  2. Space uploads/downloads/lists archives directly via HuggingFace API
 
 
 
7
  """
8
 
9
  import os
10
  import shutil
11
  import tarfile
12
+ import tempfile
13
  from datetime import datetime
14
  from pathlib import Path
15
 
16
  import httpx
17
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
18
 
19
+ from auth import AuthUser, get_current_user
20
+ from config import ADMIN_API_URL, BACKUP_DIR, DATA_DIR
21
+ from routers.terminal import kill_terminal
22
+ from storage import check_zone_owner, load_meta, save_meta, validate_zone_name
23
 
24
  router = APIRouter(prefix="/api/backup", tags=["backup"])
25
 
26
  HF_API = "https://huggingface.co/api"
27
+ _backup_status: dict = {"running": False, "last": None, "error": None, "progress": ""}
28
+
29
+
30
+ def _utc_stamp() -> str:
31
+ return datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
32
+
33
+
34
+ def _archive_file_name(zone_name: str) -> str:
35
+ return f"{zone_name}__{_utc_stamp()}.tar.gz"
36
+
37
+
38
+ def _zone_from_backup_name(name: str) -> str:
39
+ base = Path(name).name
40
+ if not base.endswith(".tar.gz"):
41
+ return base
42
+ stem = base[:-7]
43
+ return stem.split("__", 1)[0]
44
+
45
+
46
+ def _backup_sort_key(item: dict) -> tuple[str, str]:
47
+ return (item.get("last_modified") or "", item.get("backup_name") or "")
48
 
49
 
50
  def _get_token(request: Request) -> str:
 
51
  auth = request.headers.get("Authorization", "")
52
  if auth.startswith("Bearer "):
53
  return auth[7:]
 
55
 
56
 
57
  def _get_credentials(token: str) -> dict:
 
58
  with httpx.Client(timeout=15) as client:
59
  resp = client.get(
60
  f"{ADMIN_API_URL}/backup/credentials",
 
67
 
68
 
69
  def _log_action(token: str, zone_name: str, action: str, status: str, file_path: str = ""):
 
70
  try:
71
  with httpx.Client(timeout=10) as client:
72
  client.post(
 
78
  pass
79
 
80
 
81
+ def _create_zone_archive(zone_name: str) -> tuple[Path, str]:
 
82
  zone_path = DATA_DIR / zone_name
83
  if not zone_path.is_dir():
84
  raise ValueError(f"Zone '{zone_name}' khong ton tai")
85
 
86
+ fd, temp_path = tempfile.mkstemp(prefix=f"{zone_name}-", suffix=".tar.gz", dir=str(BACKUP_DIR))
87
+ os.close(fd)
88
+ archive_path = Path(temp_path)
89
  with tarfile.open(archive_path, "w:gz") as tar:
90
  tar.add(str(zone_path), arcname=zone_name)
91
+ return archive_path, _archive_file_name(zone_name)
92
 
93
 
94
+ def _assert_restore_allowed(zone_name: str, user: AuthUser):
95
+ meta = load_meta()
96
+ if zone_name in meta:
97
+ check_zone_owner(zone_name, user.sub, user.role)
98
+
99
+
100
+ def _extract_archive(archive_path: Path, zone_name: str):
101
+ zone_path = DATA_DIR / zone_name
102
+ if zone_path.exists():
103
+ shutil.rmtree(zone_path)
104
+ zone_path.mkdir(parents=True, exist_ok=True)
105
+
106
+ with tarfile.open(archive_path, "r:gz") as tar:
107
+ for member in tar.getmembers():
108
+ member_path = os.path.normpath(member.name)
109
+ if member_path.startswith("..") or os.path.isabs(member_path):
110
+ raise ValueError(f"Archive chua path khong an toan: {member.name}")
111
+ if member_path != zone_name and not member_path.startswith(f"{zone_name}/"):
112
+ raise ValueError(f"Archive chua path ngoai zone: {member.name}")
113
+ tar.extractall(path=str(DATA_DIR), filter="data")
114
+
115
+
116
+ def _ensure_restored_meta(zone_name: str, user: AuthUser):
117
+ meta = load_meta()
118
+ if zone_name not in meta:
119
+ meta[zone_name] = {
120
+ "description": "Restored from backup",
121
+ "created": datetime.now().isoformat(),
122
+ "owner_id": user.sub,
123
+ "owner_name": user.username,
124
+ }
125
+ save_meta(meta)
126
+
127
+
128
+ def _download_from_hf(creds: dict, backup_name: str) -> bytes:
129
+ file_path = f"{creds['path_prefix']}/{backup_name}"
130
+ with httpx.Client(timeout=300, follow_redirects=True) as client:
131
+ resp = client.get(
132
+ f"https://huggingface.co/datasets/{creds['repo']}/resolve/main/{file_path}",
133
+ headers={"Authorization": f"Bearer {creds['hf_token']}"},
134
+ )
135
+ if resp.status_code == 404:
136
+ raise FileNotFoundError(f"Backup '{backup_name}' khong ton tai")
137
+ if resp.status_code != 200:
138
+ raise ValueError(f"HF download error: {resp.status_code}")
139
+ return resp.content
140
+
141
+
142
+ def _list_backups_from_hf(creds: dict) -> list[dict]:
143
+ with httpx.Client(timeout=30) as client:
144
+ resp = client.get(
145
+ f"{HF_API}/datasets/{creds['repo']}/tree/main/{creds['path_prefix']}",
146
+ headers={"Authorization": f"Bearer {creds['hf_token']}"},
147
+ )
148
+ if resp.status_code == 404:
149
+ return []
150
+ if resp.status_code != 200:
151
+ raise ValueError(f"HF API error: {resp.status_code} {resp.text}")
152
+
153
+ meta = load_meta()
154
+ items: list[dict] = []
155
+ for entry in resp.json():
156
+ path = entry.get("path", "")
157
+ if entry.get("type") != "file" or not path.endswith(".tar.gz"):
158
+ continue
159
+ backup_name = path.split("/")[-1]
160
+ zone_name = _zone_from_backup_name(backup_name)
161
+ items.append(
162
+ {
163
+ "zone_name": zone_name,
164
+ "backup_name": backup_name,
165
+ "file": path,
166
+ "size": (entry.get("lfs") or {}).get("size") or entry.get("size", 0),
167
+ "last_modified": (entry.get("lastCommit") or {}).get("date", ""),
168
+ "local_exists": zone_name in meta,
169
+ }
170
+ )
171
+ return sorted(items, key=_backup_sort_key, reverse=True)
172
+
173
+
174
+ def _delete_backup_from_hf(creds: dict, backup_name: str):
175
+ from huggingface_hub import HfApi
176
+
177
+ api = HfApi(token=creds["hf_token"])
178
+ api.delete_file(
179
+ path_in_repo=f"{creds['path_prefix']}/{backup_name}",
180
+ repo_id=creds["repo"],
181
+ repo_type="dataset",
182
+ commit_message=f"Delete backup: {backup_name}",
183
+ )
184
+
185
+
186
+ def _upload_to_hf(creds: dict, backup_name: str, archive_path: Path):
187
+ from huggingface_hub import HfApi
188
+
189
+ api = HfApi(token=creds["hf_token"])
190
+ api.upload_file(
191
+ path_or_fileobj=str(archive_path),
192
+ path_in_repo=f"{creds['path_prefix']}/{backup_name}",
193
+ repo_id=creds["repo"],
194
+ repo_type="dataset",
195
+ commit_message=f"Backup: {backup_name}",
196
+ )
197
 
198
 
199
  @router.get("/status")
 
209
 
210
 
211
  @router.get("/list")
212
+ async def list_backups(request: Request, user: AuthUser = Depends(get_current_user)):
213
  if not ADMIN_API_URL:
214
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
215
+
216
  token = _get_token(request)
217
  if not token:
218
  raise HTTPException(401, "Chua dang nhap")
219
+
220
  try:
221
  creds = _get_credentials(token)
222
+ backups = _list_backups_from_hf(creds)
223
+ if user.role == "admin":
224
+ return backups
225
+
226
+ meta = load_meta()
227
+ allowed = {name for name, info in meta.items() if info.get("owner_id") == user.sub}
228
+ return [item for item in backups if item["zone_name"] in allowed or not item["local_exists"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  except ValueError as e:
230
  raise HTTPException(502, str(e))
231
  except httpx.HTTPError as e:
232
  raise HTTPException(502, f"Khong the ket noi: {e}")
233
 
234
 
235
+ @router.delete("/file")
236
+ async def delete_backup_file(
237
+ request: Request,
238
+ backup_name: str = Query(...),
239
+ user: AuthUser = Depends(get_current_user),
240
+ ):
241
+ if not ADMIN_API_URL:
242
+ raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
243
 
244
+ zone_name = _zone_from_backup_name(backup_name)
245
+ try:
246
+ _assert_restore_allowed(zone_name, user)
247
+ creds = _get_credentials(_get_token(request))
248
+ _delete_backup_from_hf(creds, backup_name)
249
+ _log_action(_get_token(request), zone_name, "delete_backup", "success", backup_name)
250
+ return {"ok": True}
251
+ except ValueError as e:
252
+ raise HTTPException(400, str(e))
253
+ except Exception as e:
254
+ raise HTTPException(502, str(e))
255
 
256
 
257
  @router.post("/zone/{zone_name}")
258
+ async def backup_zone(
259
+ zone_name: str,
260
+ request: Request,
261
+ background_tasks: BackgroundTasks,
262
+ user: AuthUser = Depends(get_current_user),
263
+ ):
264
  if not ADMIN_API_URL:
265
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
266
+
267
  token = _get_token(request)
268
  if not token:
269
  raise HTTPException(401, "Chua dang nhap")
270
+
271
  try:
272
  validate_zone_name(zone_name)
273
+ check_zone_owner(zone_name, user.sub, user.role)
274
  if not (DATA_DIR / zone_name).is_dir():
275
  raise ValueError(f"Zone '{zone_name}' khong ton tai")
276
  except ValueError as e:
277
  raise HTTPException(400, str(e))
278
+
279
  if _backup_status["running"]:
280
  raise HTTPException(409, "Dang co backup khac dang chay")
281
 
 
282
  try:
283
  creds = _get_credentials(token)
284
  except ValueError as e:
 
288
  _backup_status["running"] = True
289
  _backup_status["error"] = None
290
  _backup_status["progress"] = f"Dang backup zone: {zone_name}..."
291
+ archive_path: Path | None = None
292
+ backup_name = ""
293
  try:
294
+ archive_path, backup_name = _create_zone_archive(zone_name)
295
+ _upload_to_hf(creds, backup_name, archive_path)
296
+ _log_action(token, zone_name, "backup", "success", f"{creds['path_prefix']}/{backup_name}")
 
 
 
 
297
  _backup_status["last"] = datetime.now().isoformat()
298
  _backup_status["progress"] = f"Backup zone {zone_name} thanh cong"
299
  except Exception as e:
300
  _backup_status["error"] = str(e)
301
  _backup_status["progress"] = f"Loi backup: {e}"
302
+ _log_action(token, zone_name, "backup", "error", backup_name)
303
  finally:
304
+ if archive_path:
305
+ archive_path.unlink(missing_ok=True)
306
  _backup_status["running"] = False
307
 
308
  background_tasks.add_task(_run)
 
310
 
311
 
312
  @router.post("/all")
313
+ async def backup_all(
314
+ request: Request,
315
+ background_tasks: BackgroundTasks,
316
+ user: AuthUser = Depends(get_current_user),
317
+ ):
318
  if not ADMIN_API_URL:
319
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
320
+
321
  token = _get_token(request)
322
  if not token:
323
  raise HTTPException(401, "Chua dang nhap")
 
329
  except ValueError as e:
330
  raise HTTPException(502, str(e))
331
 
332
+ meta = load_meta()
333
+ zone_names = [
334
+ name for name, info in meta.items()
335
+ if (DATA_DIR / name).is_dir() and (user.role == "admin" or info.get("owner_id") == user.sub)
336
+ ]
337
+
338
  def _run():
339
  _backup_status["running"] = True
340
  _backup_status["error"] = None
341
  _backup_status["progress"] = "Dang backup tat ca zones..."
342
  try:
343
+ total = len(zone_names)
344
+ for idx, zone_name in enumerate(zone_names, start=1):
345
+ _backup_status["progress"] = f"Dang backup zone {zone_name} ({idx}/{total})..."
346
+ archive_path, backup_name = _create_zone_archive(zone_name)
 
 
 
 
 
347
  try:
348
+ _upload_to_hf(creds, backup_name, archive_path)
349
  finally:
350
  archive_path.unlink(missing_ok=True)
351
+ _log_action(token, zone_name, "backup", "success", f"{creds['path_prefix']}/{backup_name}")
 
 
352
  _backup_status["last"] = datetime.now().isoformat()
353
  _backup_status["progress"] = "Backup tat ca zones thanh cong"
354
  except Exception as e:
 
361
  return {"ok": True, "message": "Dang backup tat ca zones trong nen..."}
362
 
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  @router.post("/restore/{zone_name}")
365
+ async def restore_zone(
366
+ zone_name: str,
367
+ request: Request,
368
+ background_tasks: BackgroundTasks,
369
+ backup_name: str | None = Query(None),
370
+ user: AuthUser = Depends(get_current_user),
371
+ ):
372
  if not ADMIN_API_URL:
373
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
374
+
375
  token = _get_token(request)
376
  if not token:
377
  raise HTTPException(401, "Chua dang nhap")
378
+
379
  try:
380
  validate_zone_name(zone_name)
381
+ _assert_restore_allowed(zone_name, user)
382
  except ValueError as e:
383
  raise HTTPException(400, str(e))
384
+
385
  if _backup_status["running"]:
386
  raise HTTPException(409, "Dang co backup/restore khac dang chay")
387
 
388
  try:
389
  creds = _get_credentials(token)
390
+ target_backup_name = backup_name
391
+ if not target_backup_name:
392
+ backups = [item for item in _list_backups_from_hf(creds) if item["zone_name"] == zone_name]
393
+ if not backups:
394
+ raise ValueError(f"Khong tim thay backup cho zone '{zone_name}'")
395
+ target_backup_name = backups[0]["backup_name"]
396
  except ValueError as e:
397
  raise HTTPException(502, str(e))
398
 
 
400
  _backup_status["running"] = True
401
  _backup_status["error"] = None
402
  _backup_status["progress"] = f"Dang restore zone: {zone_name}..."
403
+ archive_path: Path | None = None
404
  try:
405
+ data = _download_from_hf(creds, target_backup_name)
406
+ fd, temp_path = tempfile.mkstemp(prefix=f"restore-{zone_name}-", suffix=".tar.gz", dir=str(BACKUP_DIR))
407
+ os.close(fd)
408
+ archive_path = Path(temp_path)
409
  archive_path.write_bytes(data)
410
+ kill_terminal(zone_name)
411
+ _extract_archive(archive_path, zone_name)
412
+ _ensure_restored_meta(zone_name, user)
413
+ _log_action(token, zone_name, "restore", "success", f"{creds['path_prefix']}/{target_backup_name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  _backup_status["last"] = datetime.now().isoformat()
415
  _backup_status["progress"] = f"Restore zone {zone_name} thanh cong"
416
  except Exception as e:
417
  _backup_status["error"] = str(e)
418
  _backup_status["progress"] = f"Loi restore: {e}"
419
+ _log_action(token, zone_name, "restore", "error", target_backup_name or "")
420
  finally:
421
+ if archive_path:
422
+ archive_path.unlink(missing_ok=True)
423
  _backup_status["running"] = False
424
 
425
  background_tasks.add_task(_run)
 
427
 
428
 
429
  @router.post("/restore-all")
430
+ async def restore_all(
431
+ request: Request,
432
+ background_tasks: BackgroundTasks,
433
+ user: AuthUser = Depends(get_current_user),
434
+ ):
435
  if not ADMIN_API_URL:
436
  raise HTTPException(400, "ADMIN_API_URL chua duoc cau hinh")
437
+
438
  token = _get_token(request)
439
  if not token:
440
  raise HTTPException(401, "Chua dang nhap")
 
446
  except ValueError as e:
447
  raise HTTPException(502, str(e))
448
 
449
+ try:
450
+ backups = _list_backups_from_hf(creds)
451
+ except ValueError as e:
452
+ raise HTTPException(502, str(e))
453
+
454
+ latest_by_zone: dict[str, dict] = {}
455
+ for item in backups:
456
+ zone_name = item["zone_name"]
457
+ try:
458
+ _assert_restore_allowed(zone_name, user)
459
+ except ValueError:
460
+ continue
461
+ latest_by_zone.setdefault(zone_name, item)
462
+
463
  def _run():
464
  _backup_status["running"] = True
465
  _backup_status["error"] = None
466
  _backup_status["progress"] = "Dang restore tat ca zones..."
467
  try:
468
+ items = list(latest_by_zone.values())
469
+ total = len(items)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  done = 0
471
+ for idx, item in enumerate(items, start=1):
472
+ zone_name = item["zone_name"]
473
+ backup_name = item["backup_name"]
474
+ _backup_status["progress"] = f"Dang restore zone {zone_name} ({idx}/{total})..."
475
+ archive_path: Path | None = None
 
 
 
 
 
476
  try:
477
+ data = _download_from_hf(creds, backup_name)
478
+ fd, temp_path = tempfile.mkstemp(prefix=f"restore-{zone_name}-", suffix=".tar.gz", dir=str(BACKUP_DIR))
479
+ os.close(fd)
480
+ archive_path = Path(temp_path)
481
+ archive_path.write_bytes(data)
482
+ kill_terminal(zone_name)
483
+ _extract_archive(archive_path, zone_name)
484
+ _ensure_restored_meta(zone_name, user)
485
+ done += 1
 
 
 
 
 
 
 
 
 
 
486
  finally:
487
+ if archive_path:
488
+ archive_path.unlink(missing_ok=True)
 
489
  _backup_status["last"] = datetime.now().isoformat()
490
  _backup_status["progress"] = f"Restore {done}/{total} zones thanh cong"
491
  except Exception as e:
routers/files.py CHANGED
@@ -1,144 +1,157 @@
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, 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():
34
- raise ValueError("Không phải thư mục")
35
- return [
36
- {
37
- "name": item.name,
38
- "is_dir": item.is_dir(),
39
- "size": item.stat().st_size if item.is_file() else 0,
40
- "modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
41
- }
42
- for item in sorted(target.iterdir())
43
- ]
44
- except ValueError as e:
45
- raise HTTPException(400, str(e))
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():
55
- raise ValueError("File không tồn tại")
56
- return {"content": target.read_text(encoding="utf-8", errors="replace"), "path": path}
57
- except ValueError as e:
58
- raise HTTPException(400, str(e))
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():
68
- raise HTTPException(404, "File không tồn tại")
69
- return FileResponse(target, filename=target.name)
70
- except ValueError as e:
71
- raise HTTPException(400, str(e))
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)
81
- target.write_text(content, encoding="utf-8")
82
- return {"ok": True}
83
- except ValueError as e:
84
- raise HTTPException(400, str(e))
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)
94
- return {"ok": True}
95
- except ValueError as e:
96
- raise HTTPException(400, str(e))
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)
106
- content = await file.read()
107
- dest.write_bytes(content)
108
- return {"ok": True, "path": str(dest.relative_to(zone_path))}
109
- except ValueError as e:
110
- raise HTTPException(400, str(e))
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():
120
- raise ValueError("Không thể xoá thư mục gốc zone")
121
- if target.is_dir():
122
- shutil.rmtree(target)
123
- elif target.is_file():
124
- target.unlink()
125
- else:
126
- raise ValueError("File/thư mục không tồn tại")
127
- return {"ok": True}
128
- except ValueError as e:
129
- raise HTTPException(400, str(e))
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():
139
- raise ValueError("File/thư mục nguồn không tồn tại")
140
- dest = safe_path(zone_path, str(Path(old_path).parent / new_name))
141
- source.rename(dest)
142
- return {"ok": True}
143
- except ValueError as e:
144
- raise HTTPException(400, str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import tempfile
11
+ import zipfile
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from fastapi import APIRouter, Depends, Form, File, UploadFile, Query, HTTPException
16
+ from fastapi.responses import FileResponse
17
+
18
+ from auth import AuthUser, get_current_user
19
+ from storage import get_zone_path, safe_path, check_zone_owner
20
+
21
+ router = APIRouter(prefix="/api/zones/{zone_name}/files", tags=["files"])
22
+
23
+
24
+ def _check_access(zone_name: str, user: AuthUser):
25
+ """Validate zone access for the current user."""
26
+ check_zone_owner(zone_name, user.sub, user.role)
27
+
28
+
29
+ @router.get("")
30
+ def list_files(zone_name: str, path: str = Query(""), user: AuthUser = Depends(get_current_user)):
31
+ try:
32
+ _check_access(zone_name, user)
33
+ zone_path = get_zone_path(zone_name)
34
+ target = safe_path(zone_path, path)
35
+ if not target.is_dir():
36
+ raise ValueError("Không phải thư mục")
37
+ items = sorted(target.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
38
+ return [
39
+ {
40
+ "name": item.name,
41
+ "is_dir": item.is_dir(),
42
+ "size": item.stat().st_size if item.is_file() else 0,
43
+ "modified": datetime.fromtimestamp(item.stat().st_mtime).isoformat(),
44
+ }
45
+ for item in items
46
+ ]
47
+ except ValueError as e:
48
+ raise HTTPException(400, str(e))
49
+
50
+
51
+ @router.get("/read")
52
+ def read_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
53
+ try:
54
+ _check_access(zone_name, user)
55
+ zone_path = get_zone_path(zone_name)
56
+ target = safe_path(zone_path, path)
57
+ if not target.is_file():
58
+ raise ValueError("File không tồn tại")
59
+ return {"content": target.read_text(encoding="utf-8", errors="replace"), "path": path}
60
+ except ValueError as e:
61
+ raise HTTPException(400, str(e))
62
+
63
+
64
+ @router.get("/download")
65
+ def download_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
66
+ try:
67
+ _check_access(zone_name, user)
68
+ zone_path = get_zone_path(zone_name)
69
+ target = safe_path(zone_path, path)
70
+ if target.is_file():
71
+ return FileResponse(target, filename=target.name)
72
+ if not target.is_dir():
73
+ raise HTTPException(404, "File không tồn tại")
74
+
75
+ fd, temp_name = tempfile.mkstemp(prefix=f"{target.name}-", suffix=".zip")
76
+ os.close(fd)
77
+ archive_path = Path(temp_name)
78
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zf:
79
+ for child in target.rglob("*"):
80
+ if child.is_file():
81
+ zf.write(child, arcname=child.relative_to(target.parent))
82
+ return FileResponse(archive_path, filename=f"{target.name}.zip", background=None)
83
+ except ValueError as e:
84
+ raise HTTPException(400, str(e))
85
+
86
+
87
+ @router.post("/write")
88
+ def write_file(zone_name: str, path: str = Form(...), content: 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.parent.mkdir(parents=True, exist_ok=True)
94
+ target.write_text(content, encoding="utf-8")
95
+ return {"ok": True}
96
+ except ValueError as e:
97
+ raise HTTPException(400, str(e))
98
+
99
+
100
+ @router.post("/mkdir")
101
+ def create_folder(zone_name: str, path: str = Form(...), user: AuthUser = Depends(get_current_user)):
102
+ try:
103
+ _check_access(zone_name, user)
104
+ zone_path = get_zone_path(zone_name)
105
+ target = safe_path(zone_path, path)
106
+ target.mkdir(parents=True, exist_ok=True)
107
+ return {"ok": True}
108
+ except ValueError as e:
109
+ raise HTTPException(400, str(e))
110
+
111
+
112
+ @router.post("/upload")
113
+ async def upload_file(zone_name: str, path: str = Form(""), file: UploadFile = File(...), user: AuthUser = Depends(get_current_user)):
114
+ try:
115
+ _check_access(zone_name, user)
116
+ zone_path = get_zone_path(zone_name)
117
+ dest = safe_path(zone_path, os.path.join(path, file.filename))
118
+ dest.parent.mkdir(parents=True, exist_ok=True)
119
+ content = await file.read()
120
+ dest.write_bytes(content)
121
+ return {"ok": True, "path": str(dest.relative_to(zone_path))}
122
+ except ValueError as e:
123
+ raise HTTPException(400, str(e))
124
+
125
+
126
+ @router.delete("")
127
+ def delete_file(zone_name: str, path: str = Query(...), user: AuthUser = Depends(get_current_user)):
128
+ try:
129
+ _check_access(zone_name, user)
130
+ zone_path = get_zone_path(zone_name)
131
+ target = safe_path(zone_path, path)
132
+ if target == zone_path.resolve():
133
+ raise ValueError("Không thể xoá thư mục gốc zone")
134
+ if target.is_dir():
135
+ shutil.rmtree(target)
136
+ elif target.is_file():
137
+ target.unlink()
138
+ else:
139
+ raise ValueError("File/thư mục không tồn tại")
140
+ return {"ok": True}
141
+ except ValueError as e:
142
+ raise HTTPException(400, str(e))
143
+
144
+
145
+ @router.post("/rename")
146
+ def rename_file(zone_name: str, old_path: str = Form(...), new_name: str = Form(...), user: AuthUser = Depends(get_current_user)):
147
+ try:
148
+ _check_access(zone_name, user)
149
+ zone_path = get_zone_path(zone_name)
150
+ source = safe_path(zone_path, old_path)
151
+ if not source.exists():
152
+ raise ValueError("File/thư mục nguồn không tồn tại")
153
+ dest = safe_path(zone_path, str(Path(old_path).parent / new_name))
154
+ source.rename(dest)
155
+ return {"ok": True}
156
+ except ValueError as e:
157
+ raise HTTPException(400, str(e))
routers/ports.py CHANGED
@@ -1,74 +1,85 @@
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, 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
-
16
-
17
- def _validate_port(port: int):
18
- if not (MIN_PORT <= port <= MAX_PORT):
19
- raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
20
-
21
-
22
- def _validate_zone(meta: dict, zone_name: str):
23
- if zone_name not in meta:
24
- raise ValueError(f"Zone '{zone_name}' does not exist")
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", [])
34
- except ValueError as e:
35
- raise HTTPException(400, str(e))
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
-
46
- ports = meta[zone_name].setdefault("ports", [])
47
- for p in ports:
48
- if p["port"] == port:
49
- raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
50
-
51
- entry = {"port": port, "label": label or f"Port {port}"}
52
- ports.append(entry)
53
- save_meta(meta)
54
- return entry
55
- except ValueError as e:
56
- raise HTTPException(400, str(e))
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
-
66
- ports = meta[zone_name].get("ports", [])
67
- before = len(ports)
68
- meta[zone_name]["ports"] = [p for p in ports if p["port"] != port]
69
- if len(meta[zone_name]["ports"]) == before:
70
- raise ValueError(f"Port {port} not found in zone '{zone_name}'")
71
- save_meta(meta)
72
- return {"ok": True}
73
- except ValueError as e:
74
- raise HTTPException(400, str(e))
 
 
 
 
 
 
 
 
 
 
 
 
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, Depends, Form, HTTPException
9
+
10
+ from auth import AuthUser, get_current_user
11
+ from config import MAX_PORT, MIN_PORT
12
+ from storage import check_zone_owner, load_meta, save_meta
13
+
14
+ router = APIRouter(prefix="/api/zones/{zone_name}/ports", tags=["ports"])
15
+
16
+
17
+ def _validate_port(port: int):
18
+ if not (MIN_PORT <= port <= MAX_PORT):
19
+ raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
20
+
21
+
22
+ def _validate_zone(meta: dict, zone_name: str):
23
+ if zone_name not in meta:
24
+ raise ValueError(f"Zone '{zone_name}' does not exist")
25
+
26
+
27
+ def _ensure_global_port_free(meta: dict, zone_name: str, port: int):
28
+ for name, info in meta.items():
29
+ if name == zone_name:
30
+ continue
31
+ for item in info.get("ports", []):
32
+ if item.get("port") == port:
33
+ raise ValueError(f"Port {port} is already used by zone '{name}'")
34
+
35
+
36
+ @router.get("")
37
+ def list_ports(zone_name: str, user: AuthUser = Depends(get_current_user)):
38
+ try:
39
+ check_zone_owner(zone_name, user.sub, user.role)
40
+ meta = load_meta()
41
+ _validate_zone(meta, zone_name)
42
+ return meta[zone_name].get("ports", [])
43
+ except ValueError as e:
44
+ raise HTTPException(400, str(e))
45
+
46
+
47
+ @router.post("")
48
+ def add_port(zone_name: str, port: int = Form(...), label: str = Form(""), user: AuthUser = Depends(get_current_user)):
49
+ try:
50
+ _validate_port(port)
51
+ check_zone_owner(zone_name, user.sub, user.role)
52
+ meta = load_meta()
53
+ _validate_zone(meta, zone_name)
54
+
55
+ ports = meta[zone_name].setdefault("ports", [])
56
+ for item in ports:
57
+ if item["port"] == port:
58
+ raise ValueError(f"Port {port} already mapped in zone '{zone_name}'")
59
+
60
+ _ensure_global_port_free(meta, zone_name, port)
61
+ entry = {"port": port, "label": label.strip() or f"Port {port}", "url": f"/port/{zone_name}/{port}/"}
62
+ ports.append(entry)
63
+ ports.sort(key=lambda item: item["port"])
64
+ save_meta(meta)
65
+ return entry
66
+ except ValueError as e:
67
+ raise HTTPException(400, str(e))
68
+
69
+
70
+ @router.delete("/{port}")
71
+ def remove_port(zone_name: str, port: int, user: AuthUser = Depends(get_current_user)):
72
+ try:
73
+ check_zone_owner(zone_name, user.sub, user.role)
74
+ meta = load_meta()
75
+ _validate_zone(meta, zone_name)
76
+
77
+ ports = meta[zone_name].get("ports", [])
78
+ before = len(ports)
79
+ meta[zone_name]["ports"] = [item for item in ports if item["port"] != port]
80
+ if len(meta[zone_name]["ports"]) == before:
81
+ raise ValueError(f"Port {port} not found in zone '{zone_name}'")
82
+ save_meta(meta)
83
+ return {"ok": True}
84
+ except ValueError as e:
85
+ raise HTTPException(400, str(e))
routers/proxy.py CHANGED
@@ -1,159 +1,168 @@
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, 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
-
21
- # ── Shared HTTP client ────────────────────────
22
-
23
- _HOP_HEADERS = frozenset({
24
- "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
25
- "te", "trailers", "transfer-encoding", "upgrade",
26
- })
27
-
28
- _client: httpx.AsyncClient | None = None
29
-
30
-
31
- def _get_client() -> httpx.AsyncClient:
32
- global _client
33
- if _client is None:
34
- _client = httpx.AsyncClient(
35
- timeout=httpx.Timeout(30.0, connect=5.0),
36
- follow_redirects=False,
37
- limits=httpx.Limits(max_connections=50),
38
- )
39
- return _client
40
-
41
-
42
- def _validate_proxy_access(zone_name: str, port: int):
43
- """Validate port range and check it's registered for the zone."""
44
- if not (MIN_PORT <= port <= MAX_PORT):
45
- raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
46
- meta = load_meta()
47
- if zone_name not in meta:
48
- raise ValueError(f"Zone '{zone_name}' does not exist")
49
- ports = meta[zone_name].get("ports", [])
50
- if not any(p["port"] == port for p in ports):
51
- raise ValueError("Port not mapped")
52
-
53
-
54
- # ── HTTP Reverse Proxy ────────────────────────
55
-
56
- @router.api_route(
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
-
67
- target_url = f"http://127.0.0.1:{port}/{subpath}"
68
- if request.url.query:
69
- target_url += f"?{request.url.query}"
70
-
71
- headers = {}
72
- for key, value in request.headers.items():
73
- if key.lower() not in _HOP_HEADERS and key.lower() != "host":
74
- headers[key] = value
75
- headers["host"] = f"127.0.0.1:{port}"
76
- headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
77
- headers["x-forwarded-proto"] = request.url.scheme
78
- headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
79
-
80
- body = await request.body()
81
- client = _get_client()
82
-
83
- try:
84
- resp = await client.request(method=request.method, url=target_url, headers=headers, content=body)
85
- except httpx.ConnectError:
86
- return Response(
87
- content=f"Cannot connect to port {port}. Make sure your server is running.",
88
- status_code=502,
89
- media_type="text/plain",
90
- )
91
- except httpx.TimeoutException:
92
- return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
93
-
94
- resp_headers = {}
95
- for key, value in resp.headers.items():
96
- if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
97
- resp_headers[key] = value
98
-
99
- return Response(content=resp.content, status_code=resp.status_code, headers=resp_headers)
100
-
101
-
102
- # ── WebSocket Reverse Proxy ──────────────────
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
118
-
119
- await websocket.accept()
120
- target_url = f"ws://127.0.0.1:{port}/ws/{subpath}"
121
-
122
- import websockets as ws_lib
123
-
124
- try:
125
- async with ws_lib.connect(target_url) as backend_ws:
126
- async def client_to_backend():
127
- try:
128
- while True:
129
- msg = await websocket.receive()
130
- if msg.get("type") == "websocket.disconnect":
131
- break
132
- if "text" in msg:
133
- await backend_ws.send(msg["text"])
134
- elif "bytes" in msg:
135
- await backend_ws.send(msg["bytes"])
136
- except (WebSocketDisconnect, Exception):
137
- pass
138
-
139
- async def backend_to_client():
140
- try:
141
- async for message in backend_ws:
142
- if isinstance(message, str):
143
- await websocket.send_text(message)
144
- else:
145
- await websocket.send_bytes(message)
146
- except (WebSocketDisconnect, Exception):
147
- pass
148
-
149
- await asyncio.gather(client_to_backend(), backend_to_client())
150
- except Exception:
151
- try:
152
- await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
153
- except Exception:
154
- pass
155
- finally:
156
- try:
157
- await websocket.close()
158
- except Exception:
159
- pass
 
 
 
 
 
 
 
 
 
 
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, 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
+
21
+ # ── Shared HTTP client ────────────────────────
22
+
23
+ _HOP_HEADERS = frozenset({
24
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
25
+ "te", "trailers", "transfer-encoding", "upgrade",
26
+ })
27
+
28
+ _client: httpx.AsyncClient | None = None
29
+
30
+
31
+ def _get_client() -> httpx.AsyncClient:
32
+ global _client
33
+ if _client is None:
34
+ _client = httpx.AsyncClient(
35
+ timeout=httpx.Timeout(30.0, connect=5.0),
36
+ follow_redirects=False,
37
+ limits=httpx.Limits(max_connections=50),
38
+ )
39
+ return _client
40
+
41
+
42
+
43
+
44
+ async def close_proxy_client():
45
+ global _client
46
+ if _client is not None:
47
+ await _client.aclose()
48
+ _client = None
49
+
50
+
51
+ def _validate_proxy_access(zone_name: str, port: int):
52
+ """Validate port range and check it's registered for the zone."""
53
+ if not (MIN_PORT <= port <= MAX_PORT):
54
+ raise ValueError(f"Port must be between {MIN_PORT} and {MAX_PORT}")
55
+ meta = load_meta()
56
+ if zone_name not in meta:
57
+ raise ValueError(f"Zone '{zone_name}' does not exist")
58
+ ports = meta[zone_name].get("ports", [])
59
+ if not any(p["port"] == port for p in ports):
60
+ raise ValueError("Port not mapped")
61
+
62
+
63
+ # ── HTTP Reverse Proxy ────────────────────────
64
+
65
+ @router.api_route(
66
+ "/port/{zone_name}/{port}/{subpath:path}",
67
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"],
68
+ )
69
+ async def proxy_http(request: Request, zone_name: str, port: int, subpath: str = "", user: AuthUser = Depends(get_current_user)):
70
+ try:
71
+ _validate_proxy_access(zone_name, port)
72
+ check_zone_owner(zone_name, user.sub, user.role)
73
+ except ValueError:
74
+ return Response(content="Port not mapped", status_code=404)
75
+
76
+ target_url = f"http://127.0.0.1:{port}/{subpath}"
77
+ if request.url.query:
78
+ target_url += f"?{request.url.query}"
79
+
80
+ headers = {}
81
+ for key, value in request.headers.items():
82
+ if key.lower() not in _HOP_HEADERS and key.lower() != "host":
83
+ headers[key] = value
84
+ headers["host"] = f"127.0.0.1:{port}"
85
+ headers["x-forwarded-for"] = request.client.host if request.client else "127.0.0.1"
86
+ headers["x-forwarded-proto"] = request.url.scheme
87
+ headers["x-forwarded-prefix"] = f"/port/{zone_name}/{port}"
88
+
89
+ body = await request.body()
90
+ client = _get_client()
91
+
92
+ try:
93
+ resp = await client.request(method=request.method, url=target_url, headers=headers, content=body)
94
+ except httpx.ConnectError:
95
+ return Response(
96
+ content=f"Cannot connect to port {port}. Make sure your server is running.",
97
+ status_code=502,
98
+ media_type="text/plain",
99
+ )
100
+ except httpx.TimeoutException:
101
+ return Response(content=f"Timeout connecting to port {port}", status_code=504, media_type="text/plain")
102
+
103
+ resp_headers = {}
104
+ for key, value in resp.headers.items():
105
+ if key.lower() not in _HOP_HEADERS and key.lower() != "content-encoding":
106
+ resp_headers[key] = value
107
+
108
+ return Response(content=resp.content, status_code=resp.status_code, headers=resp_headers)
109
+
110
+
111
+ # ── WebSocket Reverse Proxy ──────────────────
112
+
113
+ @router.websocket("/port/{zone_name}/{port}/ws/{subpath:path}")
114
+ async def proxy_ws(websocket: WebSocket, zone_name: str, port: int, subpath: str = ""):
115
+ # Authenticate via query parameter
116
+ user = get_ws_user(websocket)
117
+ if not user:
118
+ await websocket.close(code=4001, reason="Chưa đăng nhập")
119
+ return
120
+
121
+ try:
122
+ _validate_proxy_access(zone_name, port)
123
+ check_zone_owner(zone_name, user.sub, user.role)
124
+ except ValueError:
125
+ await websocket.close(code=4004, reason="Port not mapped")
126
+ return
127
+
128
+ await websocket.accept()
129
+ target_url = f"ws://127.0.0.1:{port}/ws/{subpath}"
130
+
131
+ import websockets as ws_lib
132
+
133
+ try:
134
+ async with ws_lib.connect(target_url) as backend_ws:
135
+ async def client_to_backend():
136
+ try:
137
+ while True:
138
+ msg = await websocket.receive()
139
+ if msg.get("type") == "websocket.disconnect":
140
+ break
141
+ if "text" in msg:
142
+ await backend_ws.send(msg["text"])
143
+ elif "bytes" in msg:
144
+ await backend_ws.send(msg["bytes"])
145
+ except (WebSocketDisconnect, Exception):
146
+ pass
147
+
148
+ async def backend_to_client():
149
+ try:
150
+ async for message in backend_ws:
151
+ if isinstance(message, str):
152
+ await websocket.send_text(message)
153
+ else:
154
+ await websocket.send_bytes(message)
155
+ except (WebSocketDisconnect, Exception):
156
+ pass
157
+
158
+ await asyncio.gather(client_to_backend(), backend_to_client())
159
+ except Exception:
160
+ try:
161
+ await websocket.send_text(json.dumps({"error": f"Cannot connect WebSocket to port {port}"}))
162
+ except Exception:
163
+ pass
164
+ finally:
165
+ try:
166
+ await websocket.close()
167
+ except Exception:
168
+ pass
routers/zones.py CHANGED
@@ -1,102 +1,148 @@
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
- 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"])
20
-
21
-
22
- def _get_max_zones() -> int:
23
- """Fetch zone limit from Admin Worker config."""
24
- if not ADMIN_API_URL:
25
- return 0
26
- try:
27
- resp = httpx.get(f"{ADMIN_API_URL}/config", timeout=5)
28
- if resp.status_code == 200:
29
- return resp.json().get("max_zones", 0)
30
- except Exception:
31
- pass
32
- return 0
33
-
34
-
35
- @router.get("")
36
- def list_zones(user: AuthUser = Depends(get_current_user)):
37
- meta = load_meta()
38
- return [
39
- {
40
- "name": name,
41
- "created": info.get("created", ""),
42
- "description": info.get("description", ""),
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)
67
- (zone_path / "README.md").write_text(
68
- f"# {name}\n\nZone được tạo lúc {datetime.now().isoformat()}\n"
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:
81
- raise HTTPException(400, str(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")
93
-
94
- kill_terminal(zone_name)
95
- shutil.rmtree(zone_path)
96
-
97
- meta = load_meta()
98
- meta.pop(zone_name, None)
99
- save_meta(meta)
100
- return {"ok": True}
101
- except ValueError as e:
102
- raise HTTPException(400, str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ 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"])
20
+
21
+
22
+ def _get_max_zones() -> int:
23
+ """Fetch zone limit from Admin Worker config."""
24
+ if not ADMIN_API_URL:
25
+ return 0
26
+ try:
27
+ resp = httpx.get(f"{ADMIN_API_URL}/config", timeout=5)
28
+ if resp.status_code == 200:
29
+ return resp.json().get("max_zones", 0)
30
+ except Exception:
31
+ pass
32
+ return 0
33
+
34
+
35
+ @router.get("")
36
+ def list_zones(user: AuthUser = Depends(get_current_user)):
37
+ meta = load_meta()
38
+ return [
39
+ {
40
+ "name": name,
41
+ "created": info.get("created", ""),
42
+ "description": info.get("description", ""),
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)
67
+ (zone_path / "README.md").write_text(
68
+ f"# {name}\n\nZone được tạo lúc {datetime.now().isoformat()}\n"
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:
81
+ raise HTTPException(400, str(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")
93
+
94
+ kill_terminal(zone_name)
95
+ shutil.rmtree(zone_path)
96
+
97
+ meta = load_meta()
98
+ meta.pop(zone_name, None)
99
+ save_meta(meta)
100
+ return {"ok": True}
101
+ except ValueError as e:
102
+ raise HTTPException(400, str(e))
103
+
104
+
105
+ @router.patch("/{zone_name}")
106
+ def update_zone(
107
+ zone_name: str,
108
+ new_name: str = Form(""),
109
+ description: str = Form(""),
110
+ user: AuthUser = Depends(get_current_user),
111
+ ):
112
+ try:
113
+ validate_zone_name(zone_name)
114
+ check_zone_owner(zone_name, user.sub, user.role)
115
+
116
+ zone_path = DATA_DIR / zone_name
117
+ if not zone_path.exists():
118
+ raise ValueError(f"Zone '{zone_name}' không tồn tại")
119
+
120
+ meta = load_meta()
121
+ info = meta.get(zone_name)
122
+ if not info:
123
+ raise ValueError(f"Zone '{zone_name}' không tồn tại")
124
+
125
+ next_name = (new_name or zone_name).strip() or zone_name
126
+ next_description = description.strip()
127
+
128
+ if next_name != zone_name:
129
+ validate_zone_name(next_name)
130
+ next_path = DATA_DIR / next_name
131
+ if next_path.exists():
132
+ raise ValueError(f"Zone '{next_name}' đã tồn tại")
133
+ zone_path.rename(next_path)
134
+ zone_path = next_path
135
+ meta[next_name] = meta.pop(zone_name)
136
+ zone_name = next_name
137
+ info = meta[zone_name]
138
+
139
+ info["description"] = next_description
140
+ save_meta(meta)
141
+ return {
142
+ "name": zone_name,
143
+ "description": info.get("description", ""),
144
+ "created": info.get("created", ""),
145
+ "exists": zone_path.is_dir(),
146
+ }
147
+ except ValueError as e:
148
+ raise HTTPException(400, str(e))