diff --git a/packetserver/http/routers/objects.py b/packetserver/http/routers/objects.py index 5afb351..eb3398a 100644 --- a/packetserver/http/routers/objects.py +++ b/packetserver/http/routers/objects.py @@ -1,5 +1,5 @@ -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from fastapi.responses import PlainTextResponse, Response, JSONResponse +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header +from fastapi.responses import PlainTextResponse, Response, JSONResponse, StreamingResponse from typing import List, Optional from datetime import datetime from uuid import UUID @@ -9,6 +9,7 @@ from traceback import format_exc import base64 import traceback from pydantic import BaseModel, model_validator +import re from packetserver.http.dependencies import get_current_http_user from packetserver.http.auth import HttpUser @@ -486,4 +487,65 @@ async def get_object_binary( private=obj.private, created_at=obj.created_at, modified_at=obj.modified_at + ) + +# Helper to sanitize filename for Content-Disposition +def sanitize_filename(filename: str) -> str: + # Remove path separators and control chars + filename = re.sub(r'[<>:"/\\|?*\x00-\x1F]', '_', filename) + return filename or "download" + +@router.get("/objects/{uuid}/download") +async def download_object( + uuid: UUID, + db: DbDependency, + current_user: HttpUser = Depends(get_current_http_user), + accept: str = Header(None) # Optional: for future content negotiation if needed +): + username = current_user.username + + try: + with db.transaction() as conn: + root = conn.root() + + obj = Object.get_object_by_uuid(uuid, root) + if not obj: + raise HTTPException(status_code=404, detail="Object not found") + + # Authorization check for private objects + if obj.private: + user = User.get_user_by_username(username, root) + if not user or user.uuid != obj.owner: + raise HTTPException(status_code=403, detail="Not authorized to access this private object") + + # Get content as bytes + content_bytes = obj.data_bytes + + # Guess content type + content_type, _ = mimetypes.guess_type(obj.name) + if content_type is None: + content_type = "application/octet-stream" if obj.binary else "text/plain" + + # Sanitize filename for header + safe_filename = sanitize_filename(obj.name) + + # Headers for download + headers = { + "Content-Disposition": f'attachment; filename="{safe_filename}"', + "Content-Length": str(obj.size), + } + + logging.info(f"User {username} downloaded object {uuid} ({obj.name}) via streaming") + + except HTTPException: + raise + except Exception as e: + logging.error(f"Download failed for {username} on {uuid}: {e}\n{traceback.format_exc()}") + raise HTTPException(status_code=500, detail="Failed to stream object") + + # Stream the bytes directly (efficient, no full load in memory beyond ZODB) + return StreamingResponse( + iter([content_bytes]), # single chunk since ZODB objects are usually small-ish + media_type=content_type, + headers=headers ) \ No newline at end of file