Files
modbison/backend/app/routers/assets.py
T

465 lines
15 KiB
Python

"""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 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 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
safe_filename = _sanitize_filename(filename)
extension = ".gltf" if lower_filename.endswith(".gltf") else ".glb"
stored_name = f"{asset.id}_{safe_filename}{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