DGSoft cfd7068af5 feat: Add blinking envelope icons for unread messages
- Implement unread message indicators with Material-UI icons
- Add BlinkingEnvelope component with theme-compatible colors
- Create UnreadMessagesContext for managing unread states
- Integrate WebSocket message handling for real-time notifications
- Icons only appear for inactive channels/DMs, disappear when opened
- Add test functionality (double-click to mark as unread)
- Fix WebSocket URL handling for production deployment
- Unify WebSocket architecture using presence connection for all messages
2025-12-12 11:26:36 +01:00

455 lines
15 KiB
Python

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