"""Asset management router.""" import json 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" def _validate_gltf_json(content: bytes) -> dict: """Validate that content is a valid .gltf file (JSON-based glTF). Returns the parsed JSON dict on success. Raises ValueError if the file is not valid glTF. """ try: data = json.loads(content) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise ValueError(f"Invalid JSON in .gltf file: {e}") if not isinstance(data, dict): raise ValueError("glTF file root must be a JSON object") if "asset" not in data: raise ValueError("Missing required 'asset' property in glTF file") if "scenes" not in data and "meshes" not in data and "nodes" not in data: # glTF 2.0 spec: at least one of scenes, meshes, nodes, accessors, # bufferViews, buffers, materials, textures, samplers, skin, # animations, or extensions must be present. # For simplicity, we require at least one of scenes/meshes/nodes. raise ValueError( "glTF file must contain at least one of: scenes, meshes, or nodes" ) return data def _validate_glb(content: bytes) -> dict: """Validate that content is a valid .glb file (binary glTF). Returns the parsed JSON chunk data on success. Raises ValueError if the file is not valid glTF. """ if len(content) < 12: raise ValueError("glb file too short") # Check magic bytes: must start with b'glTF' magic = content[:4] if magic != b"glTF": raise ValueError( f"Invalid glb magic bytes: expected b'glTF', got {magic!r}" ) # Version check (we support version 2) version = int.from_bytes(content[4:8], byteorder="little") if version != 2: raise ValueError(f"Unsupported glTF version: {version} (expected 2)") # Total length check total_length = int.from_bytes(content[8:12], byteorder="little") if total_length != len(content): raise ValueError( f"glb total length mismatch: header says {total_length}, " f"actual is {len(content)}" ) # Parse JSON chunk # Chunk format: length (4 bytes) + type (4 bytes) + data offset = 12 json_chunk_data = None while offset < len(content): if offset + 8 > len(content): raise ValueError("Incomplete chunk header in glb file") chunk_length = int.from_bytes(content[offset:offset + 4], byteorder="little") chunk_type = content[offset + 4:offset + 8] if chunk_length + 8 > len(content) - offset: raise ValueError("Chunk extends beyond file end") if chunk_type == b"JSON": json_chunk_data = content[offset + 8:offset + 8 + chunk_length] # JSON chunk may have padding break elif chunk_type == b"BIN": # Skip binary chunk offset += 8 + chunk_length continue else: # Unknown chunk type - skip it offset += 8 + chunk_length continue if json_chunk_data is None: raise ValueError("No JSON chunk found in glb file") try: data = json.loads(json_chunk_data) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise ValueError(f"Invalid JSON in glb chunk: {e}") if not isinstance(data, dict): raise ValueError("glTF file root must be a JSON object") if "asset" not in data: raise ValueError("Missing required 'asset' property in glTF file") return data def _extract_metadata(data: dict) -> dict: """Extract metadata from parsed glTF data. Returns a dict with: - bounding_box: {"min": [...], "max": [...]} or null - vertex_count: int - material_count: int - has_animation: bool """ metadata = { "bounding_box": None, "vertex_count": 0, "material_count": 0, "has_animation": False, } # Count materials if "materials" in data and isinstance(data["materials"], list): metadata["material_count"] = len(data["materials"]) # Check for animations if "animations" in data and isinstance(data["animations"], list): metadata["has_animation"] = len(data["animations"]) > 0 # Count vertices from meshes vertex_count = 0 bounds_min = None bounds_max = None if "meshes" in data and isinstance(data["meshes"], list): for mesh in data["meshes"]: if "primitives" in mesh and isinstance(mesh["primitives"], list): for primitive in mesh["primitives"]: # Accessor index for positions pos_accessor_idx = primitive.get("attributes", {}).get("POSITION") if pos_accessor_idx is not None: accessors = data.get("accessors", []) if isinstance(accessors, list) and 0 <= pos_accessor_idx < len(accessors): accessor = accessors[pos_accessor_idx] count = accessor.get("count", 0) vertex_count += count # Extract bounding box from min/max if "min" in accessor and "max" in accessor: acc_min = accessor["min"] acc_max = accessor["max"] if bounds_min is None: bounds_min = acc_min bounds_max = acc_max else: bounds_min = [ min(a, b) for a, b in zip(bounds_min, acc_min) ] bounds_max = [ max(a, b) for a, b in zip(bounds_max, acc_max) ] metadata["vertex_count"] = vertex_count if bounds_min is not None and bounds_max is not None: metadata["bounding_box"] = { "min": bounds_min, "max": bounds_max, } return metadata # --- 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, current_user: CurrentUser, file: UploadFile = File(...), 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 @router.post("/import-gltf", response_model=AssetResponse, status_code=201) async def import_gltf( current_user: CurrentUser, file: UploadFile = File(...), db: AsyncSession = Depends(get_db), ): """Import a glTF or glb file as an asset. Accepts a .gltf or .glb file, validates it, extracts metadata (bounding box, vertex count, material count, animation flag), stores the file, and returns an AssetResponse with the new asset's ID. """ filename = file.filename or "" lower_filename = filename.lower() # Validate file extension if not (lower_filename.endswith(".gltf") or lower_filename.endswith(".glb")): raise HTTPException( status_code=400, detail="File must be a .gltf or .glb file", ) # 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", ) # Validate file content based on format try: if lower_filename.endswith(".gltf"): gltf_data = _validate_gltf_json(content) else: gltf_data = _validate_glb(content) except ValueError as e: raise HTTPException( status_code=400, detail=f"Invalid glTF file: {e}", ) # Extract metadata metadata = _extract_metadata(gltf_data) # Create the asset record asset = Asset( name=Path(filename).stem, description=None, asset_type=AssetType.MODEL_GLTF, file_path="", # Will be set after file storage owner_id=current_user.id, world_id=None, is_public=False, ) db.add(asset) await db.commit() await db.refresh(asset) # Sanitize filename for storage. # The original extension was validated above, so we keep exactly one # extension after the sanitized stem. original_stem = Path(filename).stem safe_stem = _sanitize_filename(original_stem) extension = ".gltf" if lower_filename.endswith(".gltf") else ".glb" stored_name = f"{asset.id}_{safe_stem}{extension}" # Write the file to the upload directory upload_dir = Path(settings.UPLOAD_DIR) upload_dir.mkdir(parents=True, exist_ok=True) file_path = upload_dir / stored_name file_path.write_bytes(content) # Update asset with file info and metadata asset.file_path = str(file_path) asset.file_size = file_size asset.mime_type = "model/gltf+json" if lower_filename.endswith(".gltf") else "model/gltf+binary" asset.metadata_json = json.dumps(metadata) await db.commit() await db.refresh(asset) return asset