diff --git a/backend/alembic/versions/0003_add_gltf_asset_type.py b/backend/alembic/versions/0003_add_gltf_asset_type.py new file mode 100644 index 0000000..449ac6f --- /dev/null +++ b/backend/alembic/versions/0003_add_gltf_asset_type.py @@ -0,0 +1,36 @@ +"""add model_gltf value to asset_type enum + +Revision ID: 0003_add_gltf_asset_type +Revises: 0002_add_date_of_birth_to_users +Create Date: 2025-06-15 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0003_add_gltf_asset_type" +down_revision: Union[str, None] = "0002_add_date_of_birth_to_users" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add the 'model_gltf' value to the asset_type enum.""" + # IF NOT EXISTS is supported on PostgreSQL 12+; the project targets + # PostgreSQL 16. Adding an enum value and using it in the same + # transaction is restricted until commit, but this migration only adds + # the value so it is safe inside Alembic's transaction. + op.execute("ALTER TYPE asset_type ADD VALUE IF NOT EXISTS 'model_gltf'") + + +def downgrade() -> None: + """Remove the 'model_gltf' value from the asset_type enum. + + PostgreSQL does not support dropping an enum value without table scans; + this is intentionally a no-op to keep downgrades safe. Re-creating the + enum or deleting rows that use this value would be a destructive action + better performed manually if ever needed. + """ + pass diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 7d68b56..01c5845 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -73,7 +73,7 @@ def _validate_gltf_json(content: bytes) -> dict: """ try: data = json.loads(content) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: raise ValueError(f"Invalid JSON in .gltf file: {e}") if not isinstance(data, dict): @@ -105,18 +105,18 @@ def _validate_glb(content: bytes) -> dict: # Check magic bytes: must start with b'glTF' magic = content[:4] - if magic != b'glTF': + 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') + 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') + 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}, " @@ -132,17 +132,17 @@ def _validate_glb(content: bytes) -> dict: 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] + 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] + 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': + elif chunk_type == b"BIN": # Skip binary chunk offset += 8 + chunk_length continue @@ -156,7 +156,7 @@ def _validate_glb(content: bytes) -> dict: try: data = json.loads(json_chunk_data) - except json.JSONDecodeError as e: + except (json.JSONDecodeError, UnicodeDecodeError) as e: raise ValueError(f"Invalid JSON in glb chunk: {e}") if not isinstance(data, dict): @@ -442,10 +442,13 @@ async def import_gltf( await db.commit() await db.refresh(asset) - # Sanitize filename for storage - safe_filename = _sanitize_filename(filename) + # 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_filename}{extension}" + stored_name = f"{asset.id}_{safe_stem}{extension}" # Write the file to the upload directory upload_dir = Path(settings.UPLOAD_DIR)