mirror of
https://github.com/OHV-IT/collabrix.git
synced 2025-12-15 16:48:36 +01:00
- Added complete Kanban board functionality with drag-and-drop - Implemented auto-save for Kanban card editing (no more edit button) - Added route persistence to remember last visited page on reload - Improved Kanban UI design with slimmer borders and compact layout - Added checklist functionality for Kanban cards - Enhanced file upload and direct messaging features - Improved authentication and user management - Added toast notifications system - Various UI/UX improvements and bug fixes
454 lines
15 KiB
Python
454 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
|
|
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"
|
|
}
|
|
|
|
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 not current_user.is_admin:
|
|
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
|