""" File serving router for VFX Project Management System. Handles authenticated file serving, thumbnails, and access control. """ from fastapi import APIRouter, Depends, HTTPException, Response from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.orm import Session from pathlib import Path import os import mimetypes from typing import Optional from database import get_db from models.task import Task, TaskAttachment, Submission from models.user import User, UserRole from utils.auth import get_current_user_from_token, _get_user_from_db from utils.file_handler import file_handler router = APIRouter() def get_current_user( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) ): """Get current user with proper database dependency.""" return _get_user_from_db(db, token_data["user_id"]) def check_file_access_permission(user: User, task: Task, db: Session) -> bool: """Check if user has permission to access files for a task.""" from models.project import ProjectMember # Admins and coordinators can access all files if user.role == UserRole.COORDINATOR or user.is_admin: return True # Directors can access all files for review if user.role == UserRole.DIRECTOR: return True # Artists can access files for their assigned tasks if task.assigned_user_id == user.id: return True # Artists can also access files for tasks in projects they're members of if user.role == UserRole.ARTIST: # Get the project_id from the task's asset or shot project_id = None if task.asset_id: from models.asset import Asset asset = db.query(Asset).filter(Asset.id == task.asset_id).first() if asset: project_id = asset.project_id elif task.shot_id: from models.shot import Shot shot = db.query(Shot).filter(Shot.id == task.shot_id).first() if shot: from models.episode import Episode episode = db.query(Episode).filter(Episode.id == shot.episode_id).first() if episode: project_id = episode.project_id # Check if user is a project member if project_id: member = db.query(ProjectMember).filter( ProjectMember.project_id == project_id, ProjectMember.user_id == user.id ).first() if member: return True return False @router.get("/attachments/{attachment_id}") async def serve_attachment( attachment_id: int, thumbnail: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Serve a task attachment file with access control.""" # Get attachment attachment = db.query(TaskAttachment).filter( TaskAttachment.id == attachment_id, TaskAttachment.deleted_at.is_(None) ).first() if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Get associated task task = db.query(Task).filter(Task.id == attachment.task_id).first() if not task: raise HTTPException(status_code=404, detail="Associated task not found") # Check permissions if not check_file_access_permission(current_user, task, db): raise HTTPException(status_code=403, detail="Not authorized to access this file") # Determine file path if thumbnail and file_handler.is_image_file(attachment.file_path): # Try to serve thumbnail thumbnail_path = file_handler.get_thumbnail_path(attachment.file_path) absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path) if os.path.exists(absolute_thumbnail_path): file_path = absolute_thumbnail_path filename = f"thumb_{attachment.file_name}" else: # Create thumbnail on-demand created_thumbnail = file_handler.create_thumbnail(attachment.file_path) if created_thumbnail: absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail) if os.path.exists(absolute_created_thumbnail): file_path = absolute_created_thumbnail filename = f"thumb_{attachment.file_name}" else: # Fall back to original file file_path = file_handler.resolve_absolute_path(attachment.file_path) filename = attachment.file_name else: # Fall back to original file file_path = file_handler.resolve_absolute_path(attachment.file_path) filename = attachment.file_name else: file_path = file_handler.resolve_absolute_path(attachment.file_path) filename = attachment.file_name # Check if file exists if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="File not found on disk") # Get MIME type mime_type, _ = mimetypes.guess_type(file_path) if not mime_type: mime_type = 'application/octet-stream' # Return file return FileResponse( path=file_path, filename=filename, media_type=mime_type ) @router.get("/submissions/{submission_id}") async def serve_submission( submission_id: int, thumbnail: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Serve a submission file with access control.""" # Get submission submission = db.query(Submission).filter( Submission.id == submission_id, Submission.deleted_at.is_(None) ).first() if not submission: raise HTTPException(status_code=404, detail="Submission not found") # Get associated task task = db.query(Task).filter(Task.id == submission.task_id).first() if not task: raise HTTPException(status_code=404, detail="Associated task not found") # Check permissions if not check_file_access_permission(current_user, task, db): raise HTTPException(status_code=403, detail="Not authorized to access this file") # Determine file path if thumbnail and file_handler.is_image_file(submission.file_path): # Try to serve thumbnail thumbnail_path = file_handler.get_thumbnail_path(submission.file_path) absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path) if os.path.exists(absolute_thumbnail_path): file_path = absolute_thumbnail_path filename = f"thumb_{submission.file_name}" else: # Create thumbnail on-demand created_thumbnail = file_handler.create_thumbnail(submission.file_path) if created_thumbnail: absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail) if os.path.exists(absolute_created_thumbnail): file_path = absolute_created_thumbnail filename = f"thumb_{submission.file_name}" else: # Fall back to original file file_path = file_handler.resolve_absolute_path(submission.file_path) filename = submission.file_name else: # Fall back to original file file_path = file_handler.resolve_absolute_path(submission.file_path) filename = submission.file_name else: file_path = file_handler.resolve_absolute_path(submission.file_path) filename = submission.file_name # Check if file exists if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="File not found on disk") # Get MIME type mime_type, _ = mimetypes.guess_type(file_path) if not mime_type: mime_type = 'application/octet-stream' # Return file return FileResponse( path=file_path, filename=filename, media_type=mime_type ) @router.get("/submissions/{submission_id}/stream") async def stream_submission( submission_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Stream a submission file for video playback.""" # Get submission submission = db.query(Submission).filter( Submission.id == submission_id, Submission.deleted_at.is_(None) ).first() if not submission: raise HTTPException(status_code=404, detail="Submission not found") # Get associated task task = db.query(Task).filter(Task.id == submission.task_id).first() if not task: raise HTTPException(status_code=404, detail="Associated task not found") # Check permissions if not check_file_access_permission(current_user, task, db): raise HTTPException(status_code=403, detail="Not authorized to access this file") file_path = file_handler.resolve_absolute_path(submission.file_path) # Check if file exists if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="File not found on disk") # Only stream video files if not file_handler.is_video_file(file_path): raise HTTPException(status_code=400, detail="File is not a video") # Get MIME type mime_type, _ = mimetypes.guess_type(file_path) if not mime_type: mime_type = 'video/mp4' # Default for video def iterfile(file_path: str): """Generator to stream file in chunks.""" with open(file_path, mode="rb") as file_like: while True: chunk = file_like.read(1024 * 1024) # 1MB chunks if not chunk: break yield chunk return StreamingResponse( iterfile(file_path), media_type=mime_type, headers={ "Content-Disposition": f"inline; filename={submission.file_name}", "Accept-Ranges": "bytes" } ) @router.get("/info/attachment/{attachment_id}") async def get_attachment_info( attachment_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get file information for an attachment.""" # Get attachment attachment = db.query(TaskAttachment).filter( TaskAttachment.id == attachment_id, TaskAttachment.deleted_at.is_(None) ).first() if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Get associated task task = db.query(Task).filter(Task.id == attachment.task_id).first() if not task: raise HTTPException(status_code=404, detail="Associated task not found") # Check permissions if not check_file_access_permission(current_user, task, db): raise HTTPException(status_code=403, detail="Not authorized to access this file") # Get file info file_info = file_handler.get_file_info(attachment.file_path) return { "id": attachment.id, "file_name": attachment.file_name, "file_type": attachment.file_type, "file_size": attachment.file_size, "attachment_type": attachment.attachment_type, "description": attachment.description, "uploaded_at": attachment.uploaded_at, "is_image": file_handler.is_image_file(attachment.file_path), "is_video": file_handler.is_video_file(attachment.file_path), "has_thumbnail": file_handler.is_image_file(attachment.file_path), "file_exists": file_info.get('exists', False), **file_info } @router.get("/info/submission/{submission_id}") async def get_submission_info( submission_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Get file information for a submission.""" # Get submission submission = db.query(Submission).filter( Submission.id == submission_id, Submission.deleted_at.is_(None) ).first() if not submission: raise HTTPException(status_code=404, detail="Submission not found") # Get associated task task = db.query(Task).filter(Task.id == submission.task_id).first() if not task: raise HTTPException(status_code=404, detail="Associated task not found") # Check permissions if not check_file_access_permission(current_user, task, db): raise HTTPException(status_code=403, detail="Not authorized to access this file") # Get file info file_info = file_handler.get_file_info(submission.file_path) return { "id": submission.id, "file_name": submission.file_name, "version_number": submission.version_number, "notes": submission.notes, "submitted_at": submission.submitted_at, "is_image": file_handler.is_image_file(submission.file_path), "is_video": file_handler.is_video_file(submission.file_path), "has_thumbnail": file_handler.is_image_file(submission.file_path), "file_exists": file_info.get('exists', False), **file_info } @router.get("/projects/{project_id}/thumbnail") async def serve_project_thumbnail( project_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """Serve a project thumbnail image with access control.""" from models.project import Project from models.project import ProjectMember # Get project project = db.query(Project).filter(Project.id == project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") # Check if user has access to this project # Artists can only access projects they're members of if current_user.role == UserRole.ARTIST: member = db.query(ProjectMember).filter( ProjectMember.project_id == project_id, ProjectMember.user_id == current_user.id ).first() if not member: raise HTTPException(status_code=403, detail="Access denied to this project") # Check if project has a thumbnail if not project.thumbnail_path: raise HTTPException(status_code=404, detail="Project has no thumbnail") # Resolve to absolute path for file serving absolute_thumbnail_path = file_handler.resolve_absolute_path(project.thumbnail_path) # Check if file exists if not os.path.exists(absolute_thumbnail_path): raise HTTPException(status_code=404, detail="Thumbnail file not found on disk") # Get MIME type mime_type, _ = mimetypes.guess_type(absolute_thumbnail_path) if not mime_type: mime_type = 'image/jpeg' # Default for thumbnails # Return file return FileResponse( path=absolute_thumbnail_path, media_type=mime_type ) @router.get("/users/{user_id}/avatar") async def serve_user_avatar( user_id: int, db: Session = Depends(get_db) ): """Serve user avatar (public access for simplicity).""" # Get user user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") # Check if user has avatar if not user.avatar_url: raise HTTPException(status_code=404, detail="User has no avatar") # Note: Avatar access is public for simplicity since img tags can't send auth headers # More restrictive access control can be added later if needed # Resolve to absolute path for file serving absolute_avatar_path = file_handler.resolve_absolute_path(user.avatar_url) # Check if file exists if not os.path.exists(absolute_avatar_path): raise HTTPException(status_code=404, detail="Avatar file not found on disk") # Get MIME type mime_type, _ = mimetypes.guess_type(absolute_avatar_path) if not mime_type: mime_type = 'image/jpeg' # Default for avatars # Return file return FileResponse( path=absolute_avatar_path, media_type=mime_type )