LinkDesk/backend/routers/files.py

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
)