Files
Business-Management/features/storage-service/app/routers/objects.py
T
curo1305 5349f21752 feat: add storage-service container with pluggable backends (Phase 1)
New FastAPI microservice (port 8020) providing unified blob storage via
PUT/GET/DELETE/LIST HTTP API. Local filesystem backend is the default (zero
extra deps). S3-compatible and WebDAV backends are built in. Backend is
switchable at runtime via POST /migrate, which copies all objects to the new
backend, verifies each one, atomically switches, then cleans up the old backend.

WebDAV XML parsing uses defusedxml to prevent XXE attacks.

Wired into docker-compose (storage_data volume) and registered in the backend
service-health poller as 'storage-service'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 15:50:31 +02:00

61 lines
2.0 KiB
Python

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from app.services.backend_manager import get_backend
router = APIRouter()
def _validate_key(key: str) -> str:
"""Reject path traversal. Key may contain '/' for nested objects (e.g. user/doc.pdf)."""
parts = key.split("/")
if ".." in parts:
raise HTTPException(status_code=400, detail="Invalid key: path traversal not allowed")
return key
@router.put("/objects/{bucket}/{key:path}", status_code=204)
async def put_object(bucket: str, key: str, request: Request):
"""Upload raw bytes. Body is read as-is (application/octet-stream)."""
_validate_key(key)
data = await request.body()
try:
await get_backend().put(bucket, key, data)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/objects/{bucket}/{key:path}")
async def get_object(bucket: str, key: str):
"""Download raw bytes."""
_validate_key(key)
try:
data = await get_backend().get(bucket, key)
except KeyError:
raise HTTPException(status_code=404, detail="Object not found")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
return Response(content=data, media_type="application/octet-stream")
@router.delete("/objects/{bucket}/{key:path}", status_code=204)
async def delete_object(bucket: str, key: str):
"""Delete an object. No-op if it does not exist."""
_validate_key(key)
try:
await get_backend().delete(bucket, key)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/objects/{bucket}")
async def list_objects(bucket: str):
"""List all keys in a bucket."""
try:
keys = await get_backend().list_keys(bucket)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
return {"bucket": bucket, "keys": keys}