Files
Gan, Jimmy 56a8ff28ad fix: resolve CI test failures — schema, OPC mocking, download auth, router reloads
Fix 20 pre-existing test failures:
- Update mock conversation DB schema with missing columns
- Add in-memory OPC database mock to avoid PostgreSQL dependency in CI
- Fix file download endpoint to check auth before revealing file existence
- Reload routers.system and routers.security in test_app for fresh config
- Reset cached Docker client in security router between tests
- Fix websocket auth test to properly check for connection rejection

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:57:00 +08:00

223 lines
7.7 KiB
Python

import logging
import os
import re
import secrets
import shutil
import time
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Query
from fastapi.responses import StreamingResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import auth_service as auth
from config import VOLUME_ROOT
from rbac import require_admin
router = APIRouter()
public_router = APIRouter() # For endpoints that don't require auth
_bearer = HTTPBearer(auto_error=False)
logger = logging.getLogger(__name__)
BASE = Path(VOLUME_ROOT)
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB
BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"}
# Download token storage: {token: (path, expiry_timestamp)}
_download_tokens = {}
TOKEN_EXPIRY = 300 # 5 minutes
_BASE_RESOLVED = str(BASE.resolve())
def _safe_path(path: str) -> Path:
# Strip leading slashes to prevent absolute path interpretation
path = path.lstrip("/")
target = (BASE / path).resolve()
if not str(target).startswith(_BASE_RESOLVED):
raise ValueError("forbidden")
# Block access to sensitive directories
rel = target.relative_to(BASE.resolve())
if rel.parts and rel.parts[0] in BLOCKED_DIRS:
raise ValueError("forbidden")
return target
def _is_safe_symlink(item: Path) -> bool:
"""Check if a symlink resolves to a path within BASE and not in blocked dirs."""
if not item.is_symlink():
return True
try:
resolved = item.resolve()
if not str(resolved).startswith(_BASE_RESOLVED):
return False
rel = resolved.relative_to(BASE.resolve())
if rel.parts and rel.parts[0] in BLOCKED_DIRS:
return False
return True
except (OSError, ValueError):
return False
def _sanitize_filename(name: str) -> str:
name = os.path.basename(name)
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
name = re.sub(r"\.\.+", ".", name)
name = name.strip(". ")
if not name:
raise ValueError("invalid filename")
return name
@router.get("/browse", dependencies=[Depends(require_admin())])
def browse(path: str = ""):
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
# Check if we have permission to read the directory
try:
items = list(target.iterdir())
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as e:
logger.error(f"Error reading directory {target}: {e}")
raise HTTPException(status_code=500, detail="Error reading directory")
entries = []
for item in sorted(items):
try:
if not _is_safe_symlink(item):
continue
st = item.stat()
entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0})
except (PermissionError, OSError):
continue
return {"path": str(target.relative_to(BASE)), "entries": entries}
@router.post("/download-token", dependencies=[Depends(require_admin())])
def create_download_token(path: str):
"""Generate a temporary download token for large file downloads."""
try:
target = _safe_path(path)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
# Clean up expired tokens
now = time.time()
expired = [t for t, (_, exp) in _download_tokens.items() if exp < now]
for t in expired:
del _download_tokens[t]
# Generate new token
token = secrets.token_urlsafe(32)
_download_tokens[token] = (str(target), now + TOKEN_EXPIRY)
return {"token": token, "expires_in": TOKEN_EXPIRY}
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
@public_router.get("/download")
def download(path: str = None, token: str = Query(None), credentials: HTTPAuthorizationCredentials | None = Depends(_bearer)):
"""Download a file. Supports both authenticated access (path param) and token-based access (token param)."""
try:
if token:
# Token-based download (no auth required)
if token not in _download_tokens:
raise HTTPException(status_code=403, detail="Invalid or expired token")
file_path, expiry = _download_tokens[token]
if time.time() > expiry:
del _download_tokens[token]
raise HTTPException(status_code=403, detail="Token expired")
# Consume token after use
del _download_tokens[token]
target = Path(file_path)
else:
# Authenticated download — check auth before revealing file existence
if not path:
raise HTTPException(status_code=400, detail="Path or token required")
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
auth.user_from_access_token(credentials.credentials)
except HTTPException:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
def file_iterator():
with open(target, "rb") as f:
while chunk := f.read(8192 * 1024): # 8MB chunks
yield chunk
return StreamingResponse(
file_iterator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{target.name}"'}
)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
@router.post("/upload", dependencies=[Depends(require_admin())])
async def upload(path: str, file: UploadFile = File(...)):
try:
safe_name = _sanitize_filename(file.filename)
target = _safe_path(path) / safe_name
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
# Stream with size check
total = 0
with open(target, "wb") as f:
while chunk := await file.read(1024 * 1024):
total += len(chunk)
if total > MAX_UPLOAD_SIZE:
f.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="File too large (max 100MB)")
f.write(chunk)
return {"ok": True}
@router.delete("/delete", dependencies=[Depends(require_admin())])
def delete(path: str, recursive: bool = False):
try:
target = _safe_path(path)
except ValueError:
raise HTTPException(status_code=403, detail="Access denied")
if target == BASE.resolve():
raise HTTPException(status_code=403, detail="Cannot delete base root")
try:
if target.is_dir():
if not recursive:
raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true")
shutil.rmtree(target)
else:
target.unlink()
except HTTPException:
raise
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Path not found")
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as exc:
logger.exception("OS error during file deletion: %s", path)
raise HTTPException(status_code=400, detail="Failed to delete file")
return {"ok": True}