"""Asset management router.""" import os from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db from app.dependencies import CurrentUser from app.models.asset import Asset, AssetType from app.models.world import World from pydantic import BaseModel, Field router = APIRouter() # --- Pydantic schemas --- class AssetCreate(BaseModel): """Schema for creating an asset reference.""" name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = None asset_type: AssetType world_id: Optional[int] = None class AssetResponse(BaseModel): """Schema for asset responses.""" id: int name: str description: Optional[str] asset_type: AssetType file_path: str file_size: Optional[int] mime_type: Optional[str] world_id: Optional[int] owner_id: int is_public: bool created_at: str updated_at: str model_config = {"from_attributes": True} class AssetUpdate(BaseModel): """Schema for updating an asset.""" name: Optional[str] = None description: Optional[str] = None is_public: Optional[bool] = None # --- Helpers --- def _sanitize_filename(filename: str) -> str: """Strip path components and unsafe characters from a filename.""" # Remove any directory components (path traversal protection). base = os.path.basename(filename.replace("\\", "/")) # Keep only safe characters. safe = "".join(c for c in base if c.isalnum() or c in "._-") return safe or "file" # --- Endpoints --- @router.post("/", response_model=AssetResponse, status_code=201) async def create_asset( payload: AssetCreate, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Create a new asset reference.""" # Verify world ownership if world_id is provided if payload.world_id is not None: world = await db.get(World, payload.world_id) if world is None: raise HTTPException(status_code=404, detail="World not found") if world.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Not owner of this world") asset = Asset( name=payload.name, description=payload.description, asset_type=payload.asset_type, file_path="", # Will be set after upload owner_id=current_user.id, world_id=payload.world_id, is_public=False, ) db.add(asset) await db.commit() await db.refresh(asset) return asset @router.get("/", response_model=list[AssetResponse]) async def list_assets( current_user: CurrentUser, world_id: Optional[int] = Query(None, description="Filter by world"), asset_type: Optional[AssetType] = Query(None), limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), ): """List assets with optional filters.""" stmt = select(Asset).where(Asset.owner_id == current_user.id) if world_id is not None: stmt = stmt.where(Asset.world_id == world_id) if asset_type is not None: stmt = stmt.where(Asset.asset_type == asset_type) stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset) result = await db.execute(stmt) assets = result.scalars().all() return assets @router.get("/{asset_id}", response_model=AssetResponse) async def get_asset( asset_id: int, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Get a single asset by ID.""" asset = await db.get(Asset, asset_id) if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") return asset @router.patch("/{asset_id}", response_model=AssetResponse) async def update_asset( asset_id: int, payload: AssetUpdate, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Update an asset.""" asset = await db.get(Asset, asset_id) if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") update_data = payload.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(asset, field, value) await db.commit() await db.refresh(asset) return asset @router.delete("/{asset_id}", status_code=204) async def delete_asset( asset_id: int, current_user: CurrentUser, db: AsyncSession = Depends(get_db), ): """Delete an asset.""" asset = await db.get(Asset, asset_id) if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") await db.delete(asset) await db.commit() @router.post("/{asset_id}/upload", response_model=AssetResponse) async def upload_asset_file( asset_id: int, file: UploadFile = File(...), current_user: CurrentUser = Depends(), db: AsyncSession = Depends(get_db), ): """Upload a file for an existing asset.""" asset = await db.get(Asset, asset_id) if asset is None or asset.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Asset not found") # Sanitize filename to prevent path traversal. safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}") # Validate file size against configured maximum. max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 content = await file.read() file_size = len(content) if file_size > max_bytes: raise HTTPException( status_code=413, detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes", ) # Write the file to the upload directory. upload_dir = Path(settings.UPLOAD_DIR) upload_dir.mkdir(parents=True, exist_ok=True) stored_name = f"{asset_id}_{safe_filename}" file_path = upload_dir / stored_name file_path.write_bytes(content) asset.file_path = str(file_path) asset.mime_type = file.content_type asset.file_size = file_size await db.commit() await db.refresh(asset) return asset