Upload 21 files
Browse files- auth.py +110 -0
- routers/files.py +24 -10
- routers/ports.py +9 -5
- routers/proxy.py +12 -3
- routers/terminal.py +15 -1
- routers/zones.py +18 -8
- static/app.js +20 -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
|
|
|
|
| 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
|
|
|
|
| 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] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 182 |
-
if (this.token
|
| 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 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|