from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, BackgroundTasks from fastapi.responses import FileResponse, JSONResponse from sqlmodel import Session, select from typing import List, Optional import os import uuid import aiofiles from urllib.parse import quote from app.database import get_session from app.models import FileAttachment, Message, User, Channel, UserRole from app.schemas import FileAttachmentResponse, MessageResponse from app.auth import get_current_user from app.config import get_settings from app.routers.messages import user_has_channel_access from app.websocket import manager router = APIRouter(prefix="/files", tags=["Files"]) settings = get_settings() # Ensure upload directory exists os.makedirs(settings.upload_dir, exist_ok=True) @router.post("/upload/{message_id}", response_model=FileAttachmentResponse) async def upload_file( message_id: int, file: UploadFile = File(...), permission: str = Form("read"), # "read" or "write" session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Upload a file and attach it to a message with permissions""" # Validate permission if permission not in ["read", "write"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Permission must be 'read' or 'write'" ) # Get message message = session.get(Message, message_id) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Message not found" ) # Check if user has access to the channel if not user_has_channel_access(current_user, message.channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this channel" ) # Check file size file.file.seek(0, 2) # Seek to end file_size = file.file.tell() file.file.seek(0) # Reset to beginning if file_size > settings.max_upload_size: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"File size exceeds maximum allowed size of {settings.max_upload_size} bytes" ) # Generate unique filename file_extension = os.path.splitext(file.filename)[1] unique_filename = f"{uuid.uuid4()}{file_extension}" file_path = os.path.join(settings.upload_dir, unique_filename) # Save file try: async with aiofiles.open(file_path, 'wb') as out_file: content = await file.read() await out_file.write(content) except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Could not save file: {str(e)}" ) # Create file attachment record with permission and uploader file_attachment = FileAttachment( filename=unique_filename, original_filename=file.filename, mime_type=file.content_type or "application/octet-stream", file_size=file_size, file_path=file_path, message_id=message_id, upload_permission=permission, uploader_id=current_user.id ) session.add(file_attachment) session.commit() session.refresh(file_attachment) # Build response with can_edit flag response = FileAttachmentResponse( id=file_attachment.id, filename=file_attachment.filename, original_filename=file_attachment.original_filename, mime_type=file_attachment.mime_type, file_size=file_attachment.file_size, uploaded_at=file_attachment.uploaded_at, message_id=file_attachment.message_id, upload_permission=permission, uploader_id=current_user.id, is_editable=(permission == "write") ) return response @router.get("/download/{file_id}") async def download_file( file_id: int, session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Download a file""" # Get file attachment file_attachment = session.get(FileAttachment, file_id) if not file_attachment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) # Get associated message to check access message = session.get(Message, file_attachment.message_id) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Associated message not found" ) # Check if user has access to the channel if not user_has_channel_access(current_user, message.channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this file" ) # Check if file exists if not os.path.exists(file_attachment.file_path): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found on server" ) return FileResponse( path=file_attachment.file_path, filename=file_attachment.original_filename, media_type=file_attachment.mime_type ) @router.get("/message/{message_id}", response_model=List[FileAttachmentResponse]) def get_message_files( message_id: int, session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Get all files attached to a message""" # Get message message = session.get(Message, message_id) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Message not found" ) # Check access if not user_has_channel_access(current_user, message.channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this message" ) statement = select(FileAttachment).where(FileAttachment.message_id == message_id) attachments = session.exec(statement).all() return attachments @router.post("/upload-with-message/{channel_id}", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) async def upload_file_with_message( channel_id: int, file: UploadFile = File(...), permission: str = Form("read"), content: str = Form(""), reply_to_id: Optional[int] = Form(None), session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Create a message with file attachment in one request""" # Validate permission if permission not in ["read", "write"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Permission must be 'read' or 'write'" ) # Check channel access if not user_has_channel_access(current_user, channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this channel" ) # Create message message_content = content or "" new_message = Message( content=message_content, sender_id=current_user.id, channel_id=channel_id, reply_to_id=reply_to_id ) session.add(new_message) session.commit() session.refresh(new_message) # Validate file size file_content = await file.read() file_size = len(file_content) if file_size > settings.max_upload_size: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"File size exceeds maximum allowed size of {settings.max_upload_size} bytes" ) # Generate unique filename file_extension = os.path.splitext(file.filename)[1] unique_filename = f"{uuid.uuid4()}{file_extension}" file_path = os.path.join(settings.upload_dir, unique_filename) # Save file async with aiofiles.open(file_path, 'wb') as f: await f.write(file_content) # Create file attachment file_attachment = FileAttachment( filename=unique_filename, original_filename=file.filename, file_path=file_path, mime_type=file.content_type or "application/octet-stream", file_size=file_size, message_id=new_message.id, uploader_id=current_user.id, upload_permission=permission, is_editable=(permission == "write") ) session.add(file_attachment) session.commit() session.refresh(file_attachment) # Build response reply_to_data = None if reply_to_id: reply_msg = session.get(Message, reply_to_id) if reply_msg: reply_sender = session.get(User, reply_msg.sender_id) reply_to_data = { "id": reply_msg.id, "content": reply_msg.content, "sender_username": reply_sender.username if reply_sender else "Unknown", "sender_full_name": reply_sender.full_name if reply_sender else None } attachment_data = { "id": file_attachment.id, "filename": file_attachment.filename, "original_filename": file_attachment.original_filename, "mime_type": file_attachment.mime_type, "file_size": file_attachment.file_size, "uploaded_at": file_attachment.uploaded_at.isoformat(), "message_id": file_attachment.message_id, "upload_permission": file_attachment.upload_permission, "uploader_id": file_attachment.uploader_id, "is_editable": file_attachment.is_editable } response_data = { "id": new_message.id, "content": new_message.content, "channel_id": new_message.channel_id, "sender_id": new_message.sender_id, "sender_username": current_user.username, "sender_full_name": current_user.full_name, "sender_profile_picture": current_user.profile_picture, "created_at": new_message.created_at.isoformat(), "snippet_id": None, "reply_to_id": reply_to_id, "reply_to": reply_to_data, "snippet": None, "attachments": [attachment_data] } # Broadcast via WebSocket await manager.broadcast_to_channel( { "type": "message", "message": response_data }, channel_id ) return response_data @router.get("/office-uri/{file_id}") def get_office_uri( file_id: int, session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Generate Microsoft Office URI link for editing files""" # Get file attachment file_attachment = session.get(FileAttachment, file_id) if not file_attachment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) # Get associated message to check access message = session.get(Message, file_attachment.message_id) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Associated message not found" ) # Check if user has access to the channel if not user_has_channel_access(current_user, message.channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this file" ) # Check if file has write permission if file_attachment.upload_permission != "write": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="This file is read-only" ) # Generate file URL (using frontend URL from settings) file_url = f"{settings.frontend_url.replace(':5173', ':8000')}/files/download/{file_id}" encoded_url = quote(file_url, safe='') # Determine Office app based on file extension ext = os.path.splitext(file_attachment.original_filename)[1].lower() office_apps = { '.xlsx': 'ms-excel', '.xls': 'ms-excel', '.xlsm': 'ms-excel', '.docx': 'ms-word', '.doc': 'ms-word', '.pptx': 'ms-powerpoint', '.ppt': 'ms-powerpoint', '.accdb': 'ms-access', '.mpp': 'ms-project', '.vsd': 'ms-visio', '.vsdx': 'ms-visio' } app_protocol = office_apps.get(ext) if not app_protocol: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File type {ext} is not supported for Office URI" ) # Generate Office URI for editing office_uri = f"{app_protocol}:ofe|u|{file_url}" return JSONResponse({ "office_uri": office_uri, "file_url": file_url, "app": app_protocol }) @router.put("/{file_id}/permission", response_model=FileAttachmentResponse) async def update_file_permission( file_id: int, permission: str = Form(...), session: Session = Depends(get_session), current_user: User = Depends(get_current_user) ): """Update the permission of a file (only uploader or admin can change)""" # Validate permission if permission not in ["read", "write"]: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Permission must be 'read' or 'write'" ) # Get file attachment file_attachment = session.get(FileAttachment, file_id) if not file_attachment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="File not found" ) # Check if user is the uploader or an admin if file_attachment.uploader_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the uploader or an admin can change file permissions" ) # Update permission file_attachment.upload_permission = permission file_attachment.is_editable = (permission == "write") session.commit() session.refresh(file_attachment) # Get message for channel access check message = session.get(Message, file_attachment.message_id) if not message: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Associated message not found" ) # Check if user still has access to the channel if not user_has_channel_access(current_user, message.channel_id, session): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="No access to this channel" ) # Create response response = FileAttachmentResponse( id=file_attachment.id, filename=file_attachment.filename, original_filename=file_attachment.original_filename, mime_type=file_attachment.mime_type, file_size=file_attachment.file_size, uploaded_at=file_attachment.uploaded_at, message_id=file_attachment.message_id, uploader_id=file_attachment.uploader_id, upload_permission=file_attachment.upload_permission, is_editable=file_attachment.is_editable ) return response