56a8ff28ad
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>
223 lines
7.7 KiB
Python
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}
|