Add generic file attachments to chat messages

Messages can now carry an arbitrary file (MessageFile), parallel to the
existing MessageImage feature rather than a refactor of it. Files serve
with Content-Disposition: attachment to force a download and prevent an
uploaded HTML/SVG from executing same-origin. No content-type allowlist,
same 8MB cap as images for now (a separate size-limit redesign is tracked
as its own issue).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 08:02:55 -06:00
co-authored by Claude Sonnet 5
parent 89be497fdb
commit c78d7454b6
21 changed files with 697 additions and 55 deletions
+2
View File
@@ -5,6 +5,7 @@ from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.message_file import MessageFile
from app.models.message_image import MessageImage
from app.models.message_reaction import MessageReaction
from app.models.password_reset import PasswordReset
@@ -22,6 +23,7 @@ __all__ = [
"RoomMembership",
"RoomRole",
"Message",
"MessageFile",
"MessageImage",
"MessageReaction",
"InviteStatus",
+5 -3
View File
@@ -11,8 +11,8 @@ class Message(Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint(
"content IS NOT NULL OR image_id IS NOT NULL",
name="messages_content_or_image_required",
"content IS NOT NULL OR image_id IS NOT NULL OR file_id IS NOT NULL",
name="messages_content_or_attachment_required",
),
)
@@ -21,9 +21,10 @@ class Message(Base):
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
# Nullable since Phase "image uploads": a message can be an image with
# no caption. The CheckConstraint above still requires at least one of
# content/image_id.
# content/image_id/file_id.
content: Mapped[str | None] = mapped_column(Text)
image_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("message_images.id"))
file_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("message_files.id"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
@@ -32,3 +33,4 @@ class Message(Base):
user = relationship("User")
image = relationship("MessageImage")
file = relationship("MessageFile")
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class MessageFile(Base):
__tablename__ = "message_files"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
room_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rooms.id"), index=True, nullable=False)
uploaded_by: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
storage_filename: Mapped[str] = mapped_column(String(64), nullable=False)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
# Wider than MessageImage's content_type column -- generic MIME strings
# (e.g. the Office Open XML types) run 60-80 chars, unlike images'
# four known short values.
content_type: Mapped[str] = mapped_column(String(150), nullable=False)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
room = relationship("Room")
uploader = relationship("User")
+7 -7
View File
@@ -22,12 +22,12 @@ from app.services.password_service import (
)
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
ImageTooLargeError,
InvalidImageError,
delete_image,
UploadTooLargeError,
delete_file,
process_image,
read_capped,
save_image,
save_file,
)
AVATAR_MAX_DIMENSION = 512
@@ -91,7 +91,7 @@ async def upload_avatar(
try:
data = await read_capped(file)
except ImageTooLargeError:
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
try:
@@ -102,14 +102,14 @@ async def upload_avatar(
raise HTTPException(status_code=400, detail="File is not a valid image")
previous_filename = current_user.avatar_filename
storage_filename = save_image(data, ext)
storage_filename = save_file(data, ext)
current_user.avatar_filename = storage_filename
current_user.avatar_content_type = file.content_type
await db.commit()
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
delete_file(previous_filename)
return current_user
@@ -126,7 +126,7 @@ async def remove_avatar(
await db.refresh(current_user)
if previous_filename:
delete_image(previous_filename)
delete_file(previous_filename)
return current_user
+80 -6
View File
@@ -1,3 +1,4 @@
import pathlib
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
@@ -11,8 +12,9 @@ from app.dependencies import (
require_room_role,
require_scope,
)
from app.models import MessageImage, RoomRole, User
from app.schemas.message import MessageRead
from app.models import MessageFile, MessageImage, RoomRole, User
from app.schemas.message import MessageFileInfo, MessageRead
from app.schemas.message_file import MessageFileCreated
from app.schemas.message_image import MessageImageCreated
from app.schemas.room import (
MyRoomItem,
@@ -71,12 +73,13 @@ from app.services.webhook_service import (
from app.services.ssrf import UnsafeWebhookUrlError
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
MAX_FILE_BYTES,
UPLOADS_DIR,
ImageTooLargeError,
InvalidImageError,
UploadTooLargeError,
process_image,
read_capped,
save_image,
save_file,
)
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@@ -284,6 +287,15 @@ async def transfer_ownership_endpoint(
)
def _to_message_file_info(file: MessageFile) -> MessageFileInfo:
return MessageFileInfo(
id=file.id,
filename=file.original_filename,
size_bytes=file.size_bytes,
content_type=file.content_type,
)
@router.get("/{room_id}/messages", response_model=list[MessageRead])
async def get_room_messages_endpoint(
room_id: uuid.UUID,
@@ -304,6 +316,7 @@ async def get_room_messages_endpoint(
username=m.user.username,
content=m.content,
image_id=m.image_id,
file=_to_message_file_info(m.file) if m.file else None,
reactions=reactions_by_message.get(m.id, []),
created_at=m.created_at,
edited_at=m.edited_at,
@@ -326,7 +339,7 @@ async def upload_room_image_endpoint(
try:
data = await read_capped(file)
except ImageTooLargeError:
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
try:
@@ -334,7 +347,7 @@ async def upload_room_image_endpoint(
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
storage_filename = save_image(data, ext)
storage_filename = save_file(data, ext)
image = MessageImage(
room_id=room_id,
uploaded_by=current_user.id,
@@ -366,6 +379,67 @@ async def get_room_image_endpoint(
)
@router.post("/{room_id}/files", response_model=MessageFileCreated, status_code=201)
async def upload_room_file_endpoint(
room_id: uuid.UUID,
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
try:
data = await read_capped(file, cap=MAX_FILE_BYTES)
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="File exceeds 8 MB limit")
original_filename = file.filename or "file"
ext = pathlib.Path(original_filename).suffix
storage_filename = save_file(data, ext)
message_file = MessageFile(
room_id=room_id,
uploaded_by=current_user.id,
storage_filename=storage_filename,
original_filename=original_filename,
content_type=file.content_type or "application/octet-stream",
size_bytes=len(data),
)
db.add(message_file)
await db.commit()
await db.refresh(message_file)
return MessageFileCreated(
id=message_file.id,
filename=message_file.original_filename,
size_bytes=message_file.size_bytes,
content_type=message_file.content_type,
)
@router.get("/{room_id}/files/{file_id}")
async def get_room_file_endpoint(
room_id: uuid.UUID,
file_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
message_file = await db.get(MessageFile, file_id)
if message_file is None or message_file.room_id != room_id:
raise HTTPException(status_code=404, detail="File not found")
# `filename=` makes Starlette set Content-Disposition: attachment,
# forcing a download instead of an inline render regardless of
# content-type -- the mitigation for a same-origin-served, user-
# uploaded file (e.g. .html/.svg) executing script in this app's own
# origin if opened directly. No content-type allowlist needed on top
# of this; see backend/README.md.
return FileResponse(
UPLOADS_DIR / message_file.storage_filename,
media_type=message_file.content_type,
filename=message_file.original_filename,
headers={"Cache-Control": "private, max-age=31536000, immutable"},
)
@router.post("/{room_id}/members", response_model=RoomMemberRead, status_code=201)
async def add_member_endpoint(
room_id: uuid.UUID,
+10
View File
@@ -10,6 +10,15 @@ class ReactionSummary(BaseModel):
user_ids: list[str]
class MessageFileInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
filename: str
size_bytes: int
content_type: str
class MessageRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -19,6 +28,7 @@ class MessageRead(BaseModel):
username: str
content: str | None
image_id: uuid.UUID | None
file: MessageFileInfo | None
reactions: list[ReactionSummary]
created_at: datetime
edited_at: datetime | None
+10
View File
@@ -0,0 +1,10 @@
import uuid
from pydantic import BaseModel
class MessageFileCreated(BaseModel):
id: uuid.UUID
filename: str
size_bytes: int
content_type: str
+20 -8
View File
@@ -3,7 +3,7 @@ import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Message, Room, RoomMembership, User
from app.models import Message, MessageFile, Room, RoomMembership, User
from app.schemas.message import ReactionSummary
from app.services.push_service import send_push_to_user
from app.services.webhook_service import dispatch_event
@@ -27,11 +27,12 @@ async def _notify_offline_members(
return
room = await db.get(Room, room_id)
body = (
f"{sender.username}: {message.content}"[:120]
if message.content
else f"{sender.username} sent an image"
)
if message.content:
body = f"{sender.username}: {message.content}"[:120]
elif message.file_id:
body = f"{sender.username} sent a file"
else:
body = f"{sender.username} sent an image"
payload = {
"title": f"#{room.name}" if room else "New message",
"body": body,
@@ -41,7 +42,17 @@ async def _notify_offline_members(
await send_push_to_user(db, user_id, payload)
def _message_payload(message: Message, username: str) -> dict:
async def _message_payload(db: AsyncSession, message: Message, username: str) -> dict:
file_payload = None
if message.file_id:
message_file = await db.get(MessageFile, message.file_id)
if message_file:
file_payload = {
"id": str(message_file.id),
"filename": message_file.original_filename,
"size_bytes": message_file.size_bytes,
"content_type": message_file.content_type,
}
return {
"type": "message",
"id": str(message.id),
@@ -50,6 +61,7 @@ def _message_payload(message: Message, username: str) -> dict:
"username": username,
"content": message.content,
"image_id": str(message.image_id) if message.image_id else None,
"file": file_payload,
"reactions": [],
"created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
@@ -67,7 +79,7 @@ async def broadcast_new_message(
"""The full side-effect sequence for a newly created message, shared by
the WS "message" handler and the incoming-webhook receiver so both
trigger identical fan-out/push/event behavior."""
payload = _message_payload(message, sender.username)
payload = await _message_payload(db, message, sender.username)
await broadcaster.publish(room_id, payload)
await _notify_offline_members(db, presence, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload)
+5 -2
View File
@@ -24,8 +24,11 @@ async def create_message(
user_id: uuid.UUID,
content: str | None = None,
image_id: uuid.UUID | None = None,
file_id: uuid.UUID | None = None,
) -> Message:
message = Message(room_id=room_id, user_id=user_id, content=content, image_id=image_id)
message = Message(
room_id=room_id, user_id=user_id, content=content, image_id=image_id, file_id=file_id
)
db.add(message)
await db.commit()
await db.refresh(message)
@@ -54,7 +57,7 @@ async def list_recent_messages(
result = await db.execute(
select(Message)
.where(Message.room_id == room_id)
.options(selectinload(Message.user))
.options(selectinload(Message.user), selectinload(Message.file))
.order_by(Message.created_at.desc())
.limit(limit)
)
+8 -4
View File
@@ -11,6 +11,10 @@ from PIL import Image, UnidentifiedImageError
UPLOADS_DIR = pathlib.Path(__file__).resolve().parent.parent.parent / "uploads"
MAX_IMAGE_BYTES = 8 * 1024 * 1024
# Separate named constant (same value for now) so a later size-limit
# redesign for generic file attachments doesn't have to touch image
# behavior.
MAX_FILE_BYTES = MAX_IMAGE_BYTES
_READ_CHUNK_BYTES = 1024 * 1024
_MAX_DIMENSION = 2000
@@ -23,7 +27,7 @@ ALLOWED_IMAGE_CONTENT_TYPES: dict[str, tuple[str, str]] = {
}
class ImageTooLargeError(Exception):
class UploadTooLargeError(Exception):
pass
@@ -43,7 +47,7 @@ async def read_capped(file, cap: int = MAX_IMAGE_BYTES) -> bytes:
break
total += len(chunk)
if total > cap:
raise ImageTooLargeError()
raise UploadTooLargeError()
chunks.append(chunk)
return b"".join(chunks)
@@ -89,14 +93,14 @@ def process_image(
return out.getvalue(), ext
def save_image(data: bytes, ext: str) -> str:
def save_file(data: bytes, ext: str) -> str:
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
storage_filename = f"{uuid.uuid4()}{ext}"
(UPLOADS_DIR / storage_filename).write_bytes(data)
return storage_filename
def delete_image(storage_filename: str) -> None:
def delete_file(storage_filename: str) -> None:
"""Best-effort delete -- a missing file (already gone, or never
written) is not an error."""
(UPLOADS_DIR / storage_filename).unlink(missing_ok=True)
+19 -4
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ApiToken, Message, MessageImage, RoomMembership, User
from app.models import ApiToken, Message, MessageFile, MessageImage, RoomMembership, User
from app.services.bot_service import resolve_token
from app.services.message_events import (
broadcast_message_update,
@@ -31,6 +31,7 @@ class ClientEnvelope(BaseModel):
room_id: uuid.UUID | None = None
content: str | None = None
image_id: uuid.UUID | None = None
file_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None
emoji: str | None = None
@@ -110,9 +111,16 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
joined_rooms.discard(envelope.room_id)
elif envelope.type == "message":
if envelope.room_id is None or (not envelope.content and envelope.image_id is None):
if envelope.room_id is None or (
not envelope.content
and envelope.image_id is None
and envelope.file_id is None
):
await websocket.send_json(
{"type": "error", "detail": "room_id and content or image_id required"}
{
"type": "error",
"detail": "room_id and content or image_id or file_id required",
}
)
continue
if _missing_scope(api_token, "write:messages"):
@@ -134,8 +142,15 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
await websocket.send_json({"type": "error", "detail": "Invalid image"})
continue
image_id = image.id
file_id = None
if envelope.file_id is not None:
message_file = await db.get(MessageFile, envelope.file_id)
if message_file is None or message_file.room_id != envelope.room_id:
await websocket.send_json({"type": "error", "detail": "Invalid file"})
continue
file_id = message_file.id
message = await create_message(
db, envelope.room_id, user.id, envelope.content, image_id
db, envelope.room_id, user.id, envelope.content, image_id, file_id
)
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)