""" TLDraw Supabase Storage Router ============================= Handles TLDraw snapshot operations using Supabase Storage instead of local filesystem. This replaces the old filesystem-based tldraw_filesystem.py router. """ from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv()) import os import json import logging from fastapi import APIRouter, Depends, HTTPException, Query from typing import Dict, Any, Tuple from modules.auth.supabase_bearer import SupabaseBearer from modules.database.supabase.utils.client import SupabaseServiceRoleClient from modules.database.supabase.utils.storage import StorageError, StorageUser from modules.logger_tool import initialise_logger router = APIRouter() logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True) ALLOWED_SNAPSHOT_BUCKETS = {"cc.public.snapshots"} PERSONAL_NODE_TYPES = {"User", "Teacher", "Developer", "SuperAdmin", "UserTeacherTimetable"} GLOBAL_READONLY_NODE_TYPES = {"CalendarYear", "CalendarMonth", "CalendarWeek", "CalendarDay", "CalendarTimeChunk"} def _sb() -> SupabaseServiceRoleClient: return SupabaseServiceRoleClient() def _parse_snapshot_path(path: str) -> Tuple[str, str, str, str]: """Parse and validate bucket/node_type/node_id into a storage object path.""" if not path: raise HTTPException(status_code=400, detail="Path not provided") path_parts = [part for part in path.split('/') if part] if len(path_parts) != 3: raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id") bucket, node_type, node_id = path_parts if bucket not in ALLOWED_SNAPSHOT_BUCKETS: raise HTTPException(status_code=403, detail="Snapshot bucket is not allowed") if any(part in {".", ".."} or ".." in part for part in path_parts): raise HTTPException(status_code=400, detail="Invalid path component") if not node_type.replace("_", "").replace("-", "").isalnum(): raise HTTPException(status_code=400, detail="Invalid node type") if not node_id.replace("_", "").replace("-", "").isalnum(): raise HTTPException(status_code=400, detail="Invalid node id") return bucket, node_type, node_id, f"{node_type}/{node_id}/tldraw_file.json" def _user_scope(user_id: str) -> Dict[str, Any]: """Resolve Supabase/Neo4j scope for the authenticated user.""" scope: Dict[str, Any] = { "user_id": user_id, "teacher_db": f"cc.users.teacher.{user_id.replace('-', '')}" if user_id else "", "institute_id": "", "institute_db": "", "curriculum_db": "", } if not user_id: return scope try: sb = _sb() prof = sb.supabase.table("profiles").select("school_id").eq("id", user_id).single().execute() school_id = str((prof.data or {}).get("school_id") or "") scope["institute_id"] = school_id if school_id: inst = sb.supabase.table("institutes").select("neo4j_uuid_string").eq("id", school_id).single().execute() neo4j_uuid = (inst.data or {}).get("neo4j_uuid_string") if neo4j_uuid: scope["institute_db"] = f"cc.institutes.{neo4j_uuid}" scope["curriculum_db"] = f"cc.institutes.{neo4j_uuid}.curriculum" except Exception as exc: logger.warning(f"Could not resolve TLDraw storage scope for user {user_id}: {exc}") return scope def _authorize_snapshot_path(path: str, db_name: str, credentials: Dict[str, Any], write: bool) -> Tuple[str, str, str, str]: """Authorize TLDraw snapshot access before touching Supabase Storage.""" user_id = credentials.get("sub", "") if not user_id: raise HTTPException(status_code=403, detail="Could not extract user_id from token") bucket, node_type, node_id, file_path = _parse_snapshot_path(path) scope = _user_scope(user_id) allowed_dbs = {db for db in (scope["teacher_db"], scope["institute_db"], scope["curriculum_db"]) if db} if node_type in PERSONAL_NODE_TYPES: if node_id == user_id or (db_name and db_name == scope["teacher_db"]): return bucket, node_type, node_id, file_path raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's workspace") if node_type in GLOBAL_READONLY_NODE_TYPES and db_name == "classroomcopilot": if write: raise HTTPException(status_code=403, detail="Global calendar snapshots are read-only") return bucket, node_type, node_id, file_path # Institute/curriculum snapshots must be accessed through the caller's institute DB. if db_name and db_name in allowed_dbs: return bucket, node_type, node_id, file_path raise HTTPException(status_code=403, detail="Snapshot path is outside the authenticated user's tenant") def _storage_for_user(credentials: Dict[str, Any]) -> StorageUser: access_token = credentials.get("_access_token") if not access_token: raise HTTPException(status_code=403, detail="User access token is required for storage access") return StorageUser(user_id=credentials.get("sub"), access_token=access_token) def _is_valid_tldraw_snapshot(snapshot_data: Any) -> bool: if not isinstance(snapshot_data, dict): return False if not ("document" in snapshot_data and "session" in snapshot_data): return False document = snapshot_data.get("document") if isinstance(document, dict) and "schema" in document: return True return "schemaVersion" in snapshot_data def create_default_tldraw_content(): """Create default tldraw content structure.""" return { "document": { "store": { "document:document": { "gridSize": 10, "name": "", "meta": {}, "id": "document:document", "typeName": "document" }, "page:page": { "meta": {}, "id": "page:page", "name": "Page 1", "index": "a1", "typeName": "page" } }, "schema": { "schemaVersion": 2, "sequences": { "com.tldraw.store": 4, "com.tldraw.asset": 1, "com.tldraw.camera": 1, "com.tldraw.document": 2, "com.tldraw.instance": 25, "com.tldraw.instance_page_state": 5, "com.tldraw.page": 1, "com.tldraw.instance_presence": 5, "com.tldraw.pointer": 1, "com.tldraw.shape": 4, "com.tldraw.asset.bookmark": 2, "com.tldraw.asset.image": 5, "com.tldraw.asset.video": 5, "com.tldraw.shape.arrow": 5, "com.tldraw.shape.bookmark": 2, "com.tldraw.shape.draw": 2, "com.tldraw.shape.embed": 4, "com.tldraw.shape.frame": 0, "com.tldraw.shape.geo": 9, "com.tldraw.shape.group": 0, "com.tldraw.shape.highlight": 1, "com.tldraw.shape.image": 4, "com.tldraw.shape.line": 5, "com.tldraw.shape.note": 8, "com.tldraw.shape.text": 2, "com.tldraw.shape.video": 2, "com.tldraw.binding.arrow": 0 } }, "recordVersions": { "asset": {"version": 1, "subTypeKey": "type", "subTypeVersions": {}}, "camera": {"version": 1}, "document": {"version": 2}, "instance": {"version": 21}, "instance_page_state": {"version": 5}, "page": {"version": 1}, "shape": {"version": 3, "subTypeKey": "type", "subTypeVersions": {}}, "instance_presence": {"version": 5}, "pointer": {"version": 1} }, "rootShapeIds": [], "bindings": [], "assets": [] }, "session": { "version": 0, "currentPageId": "page:page", "pageStates": [{ "pageId": "page:page", "camera": {"x": 0, "y": 0, "z": 1}, "selectedShapeIds": [] }] } } @router.get("/get_tldraw_node_file") async def read_tldraw_node_file_from_supabase( path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"), db_name: str = Query(..., description="Database name for context"), credentials: dict = Depends(SupabaseBearer()), ): """ Load TLDraw snapshot from Supabase Storage. Args: path: Supabase Storage path in format 'bucket/nodetype/node_id' db_name: Database name for context (used for logging) Returns: TLDraw snapshot data """ logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}") logger.debug(f"Database name: {db_name}") try: bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=False) storage = _storage_for_user(credentials) logger.debug(f"Bucket: {bucket}") logger.debug(f"File path: {file_path}") try: # Try to download the file from Supabase Storage file_data = storage.download_file(bucket, file_path) # Parse JSON data try: snapshot_data = json.loads(file_data.decode('utf-8')) except (UnicodeDecodeError, json.JSONDecodeError) as e: logger.warning(f"Malformed TLDraw snapshot {file_path}; returning default content: {e}") return create_default_tldraw_content() logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}") if _is_valid_tldraw_snapshot(snapshot_data): return snapshot_data logger.warning(f"Snapshot data from {file_path} is missing required TLDraw structure. Using default structure.") return create_default_tldraw_content() except StorageError as e: # File doesn't exist, create default content logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}") # Create default tldraw content default_content = create_default_tldraw_content() try: # Upload default content to Supabase Storage json_data = json.dumps(default_content, indent=2).encode('utf-8') storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True) logger.info(f"Default tldraw file created in Supabase Storage: {file_path}") return default_content except Exception as upload_error: logger.error(f"Error creating default tldraw file in Supabase Storage: {upload_error}") raise HTTPException(status_code=500, detail="Error creating default tldraw file") except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Unexpected error loading tldraw file from Supabase Storage: {e}") raise HTTPException(status_code=500, detail=f"Error loading file: {str(e)}") @router.post("/set_tldraw_node_file") async def set_tldraw_node_file_in_supabase( path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"), db_name: str = Query(..., description="Database name for context"), data: Dict[str, Any] = None, credentials: dict = Depends(SupabaseBearer()), ): """ Save TLDraw snapshot to Supabase Storage. Args: path: Supabase Storage path in format 'bucket/nodetype/node_id' db_name: Database name for context (used for logging) data: TLDraw snapshot data to save Returns: Success status """ logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}") logger.debug(f"Database name: {db_name}") if not data: raise HTTPException(status_code=400, detail="Data not provided") try: bucket, node_type, node_id, file_path = _authorize_snapshot_path(path, db_name, credentials, write=True) storage = _storage_for_user(credentials) logger.debug(f"Bucket: {bucket}") logger.debug(f"File path: {file_path}") # Convert data to JSON try: json_data = json.dumps(data, indent=2).encode('utf-8') except (TypeError, ValueError) as e: logger.error(f"Failed to serialize data to JSON: {e}") raise HTTPException(status_code=400, detail="Invalid data format") # Upload to Supabase Storage try: storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True) logger.info(f"Successfully saved tldraw snapshot to Supabase Storage: {file_path}") return {"status": "success", "message": "File saved successfully"} except Exception as upload_error: logger.error(f"Error uploading file to Supabase Storage: {upload_error}") raise HTTPException(status_code=500, detail="Error saving file") except HTTPException: # Re-raise HTTP exceptions raise except Exception as e: logger.error(f"Unexpected error saving tldraw file to Supabase Storage: {e}") raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")