448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""
|
|
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
|
|
)
|