Add image uploads in chat messages (Gitea issue #10)

Images live on the app server's local disk (uploads/), served through an
authenticated, room-membership-gated endpoint since rooms can be private.
Uploads are streamed with a byte-count cap, validated as genuine decodable
images with Pillow (not just a spoofed Content-Type), and downscaled to
2000px on the longer side (except GIF, to preserve animation).

Backend: MessageImage model + nullable Message.content/image_id with a
content-or-image CheckConstraint, upload/serve endpoints in rooms.py, WS
message envelope gains image_id, push notification body says "sent an
image" for image-only messages.

Frontend: Composer gets an attach button with upload progress and a
thumbnail chip; MessageList renders images inline with a click-to-zoom
ImageLightbox.
This commit is contained in:
2026-08-14 12:21:42 -06:00
parent 559adf9b7e
commit f2a59f798b
27 changed files with 829 additions and 40 deletions
+2
View File
@@ -5,6 +5,7 @@ from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.message_image import MessageImage
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.user import User
@@ -17,6 +18,7 @@ __all__ = [
"RoomMembership",
"RoomRole",
"Message",
"MessageImage",
"RoomInvite",
"InviteStatus",
"PushSubscription",
+13 -2
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Text, func
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
@@ -9,11 +9,21 @@ from app.models.base import Base
class Message(Base):
__tablename__ = "messages"
__table_args__ = (
CheckConstraint(
"content IS NOT NULL OR image_id IS NOT NULL",
name="messages_content_or_image_required",
),
)
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)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
content: Mapped[str] = mapped_column(Text, 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: Mapped[str | None] = mapped_column(Text)
image_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("message_images.id"))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
@@ -21,3 +31,4 @@ class Message(Base):
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user = relationship("User")
image = relationship("MessageImage")
+27
View File
@@ -0,0 +1,27 @@
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 MessageImage(Base):
__tablename__ = "message_images"
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)
# The on-disk filename -- a generated UUID + real extension, never the
# client-supplied original filename (avoids path-traversal/collision
# concerns from trusting client input for a filesystem path).
storage_filename: Mapped[str] = mapped_column(String(64), nullable=False)
content_type: Mapped[str] = mapped_column(String(50), 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")
+68 -2
View File
@@ -1,6 +1,7 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
@@ -10,9 +11,10 @@ from app.dependencies import (
require_room_role,
require_scope,
)
from app.models import RoomRole, User
from app.models import MessageImage, RoomRole, User
from app.schemas.invite import InviteCreate, InviteRead
from app.schemas.message import MessageRead
from app.schemas.message_image import MessageImageCreated
from app.schemas.room import (
MyRoomItem,
RoomCreate,
@@ -74,6 +76,15 @@ from app.services.webhook_service import (
revoke_incoming_webhook,
)
from app.services.ssrf import UnsafeWebhookUrlError
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
UPLOADS_DIR,
ImageTooLargeError,
InvalidImageError,
process_image,
read_capped,
save_image,
)
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
@@ -291,6 +302,7 @@ async def get_room_messages_endpoint(
user_id=m.user_id,
username=m.user.username,
content=m.content,
image_id=m.image_id,
created_at=m.created_at,
edited_at=m.edited_at,
)
@@ -298,6 +310,60 @@ async def get_room_messages_endpoint(
]
@router.post("/{room_id}/images", response_model=MessageImageCreated, status_code=201)
async def upload_room_image_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)
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
try:
data = await read_capped(file)
except ImageTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
try:
data, ext = process_image(data, file.content_type)
except InvalidImageError:
raise HTTPException(status_code=400, detail="File is not a valid image")
storage_filename = save_image(data, ext)
image = MessageImage(
room_id=room_id,
uploaded_by=current_user.id,
storage_filename=storage_filename,
content_type=file.content_type,
size_bytes=len(data),
)
db.add(image)
await db.commit()
await db.refresh(image)
return MessageImageCreated(id=image.id)
@router.get("/{room_id}/images/{image_id}")
async def get_room_image_endpoint(
room_id: uuid.UUID,
image_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await require_room_member(room_id, current_user, db)
image = await db.get(MessageImage, image_id)
if image is None or image.room_id != room_id:
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(
UPLOADS_DIR / image.storage_filename,
media_type=image.content_type,
headers={"Cache-Control": "private, max-age=31536000, immutable"},
)
def _to_invite_read(invite) -> InviteRead:
return InviteRead(
id=invite.id,
+2 -1
View File
@@ -11,6 +11,7 @@ class MessageRead(BaseModel):
room_id: uuid.UUID
user_id: uuid.UUID
username: str
content: str
content: str | None
image_id: uuid.UUID | None
created_at: datetime
edited_at: datetime | None
+7
View File
@@ -0,0 +1,7 @@
import uuid
from pydantic import BaseModel
class MessageImageCreated(BaseModel):
id: uuid.UUID
+9 -3
View File
@@ -11,7 +11,7 @@ from app.ws.presence import Presence
async def _notify_offline_members(
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, content: str
db: AsyncSession, presence: Presence, room_id: uuid.UUID, sender: User, message: Message
) -> None:
result = await db.execute(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
@@ -26,9 +26,14 @@ 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"
)
payload = {
"title": f"#{room.name}" if room else "New message",
"body": f"{sender.username}: {content}"[:120],
"body": body,
"room_id": str(room_id),
}
for user_id in offline_ids:
@@ -43,6 +48,7 @@ def _message_payload(message: Message, username: str) -> dict:
"user_id": str(message.user_id),
"username": username,
"content": message.content,
"image_id": str(message.image_id) if message.image_id else None,
"created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None,
}
@@ -61,7 +67,7 @@ async def broadcast_new_message(
trigger identical fan-out/push/event behavior."""
payload = _message_payload(message, sender.username)
await broadcaster.publish(room_id, payload)
await _notify_offline_members(db, presence, room_id, sender, message.content)
await _notify_offline_members(db, presence, room_id, sender, message)
await dispatch_event(db, "message.created", room_id, payload)
+6 -2
View File
@@ -17,9 +17,13 @@ class NotMessageAuthorError(Exception):
async def create_message(
db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UUID, content: str
db: AsyncSession,
room_id: uuid.UUID,
user_id: uuid.UUID,
content: str | None = None,
image_id: uuid.UUID | None = None,
) -> Message:
message = Message(room_id=room_id, user_id=user_id, content=content)
message = Message(room_id=room_id, user_id=user_id, content=content, image_id=image_id)
db.add(message)
await db.commit()
await db.refresh(message)
+82
View File
@@ -0,0 +1,82 @@
import io
import pathlib
import uuid
from PIL import Image, UnidentifiedImageError
# backend/app/storage.py -> backend/ -> repo root -- same
# resolve-relative-to-file convention FRONTEND_DIST uses in app/main.py, so
# this lands in the right place in both local dev and the /srv/chatapp
# production layout with zero new config.
UPLOADS_DIR = pathlib.Path(__file__).resolve().parent.parent.parent / "uploads"
MAX_IMAGE_BYTES = 8 * 1024 * 1024
_READ_CHUNK_BYTES = 1024 * 1024
_MAX_DIMENSION = 2000
# (storage extension, Pillow format name)
ALLOWED_IMAGE_CONTENT_TYPES: dict[str, tuple[str, str]] = {
"image/jpeg": (".jpg", "JPEG"),
"image/png": (".png", "PNG"),
"image/gif": (".gif", "GIF"),
"image/webp": (".webp", "WEBP"),
}
class ImageTooLargeError(Exception):
pass
class InvalidImageError(Exception):
pass
async def read_capped(file, cap: int = MAX_IMAGE_BYTES) -> bytes:
"""Reads an UploadFile-like object in chunks, raising as soon as `cap`
is exceeded rather than after buffering the whole (potentially huge)
body first."""
chunks = []
total = 0
while True:
chunk = await file.read(_READ_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > cap:
raise ImageTooLargeError()
chunks.append(chunk)
return b"".join(chunks)
def process_image(data: bytes, content_type: str) -> tuple[bytes, str]:
"""Confirms `data` is a genuinely decodable image (not just a spoofed
Content-Type header) and downscales it so its longer side is
<=2000px -- except GIF, left untouched so animation isn't collapsed to
a single frame. Returns (final_bytes, storage_extension)."""
ext, pillow_format = ALLOWED_IMAGE_CONTENT_TYPES[content_type]
try:
with Image.open(io.BytesIO(data)) as probe:
probe.verify()
except (UnidentifiedImageError, OSError, ValueError) as exc:
raise InvalidImageError() from exc
if content_type == "image/gif":
return data, ext
# verify() leaves the image unusable for further processing, so reopen.
image = Image.open(io.BytesIO(data))
image.load()
if pillow_format == "JPEG" and image.mode in ("RGBA", "P"):
image = image.convert("RGB")
image.thumbnail((_MAX_DIMENSION, _MAX_DIMENSION))
out = io.BytesIO()
image.save(out, format=pillow_format)
return out.getvalue(), ext
def save_image(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
+14 -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, RoomMembership, User
from app.models import ApiToken, MessageImage, RoomMembership, User
from app.services.bot_service import resolve_token
from app.services.message_events import broadcast_message_update, broadcast_new_message
from app.services.message_service import (
@@ -25,6 +25,7 @@ class ClientEnvelope(BaseModel):
type: str
room_id: uuid.UUID | None = None
content: str | None = None
image_id: uuid.UUID | None = None
message_id: uuid.UUID | None = None
@@ -103,9 +104,9 @@ 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:
if envelope.room_id is None or (not envelope.content and envelope.image_id is None):
await websocket.send_json(
{"type": "error", "detail": "room_id and content required"}
{"type": "error", "detail": "room_id and content or image_id required"}
)
continue
if _missing_scope(api_token, "write:messages"):
@@ -120,7 +121,16 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
{"type": "error", "detail": "Not a member of this room"}
)
continue
message = await create_message(db, envelope.room_id, user.id, envelope.content)
image_id = None
if envelope.image_id is not None:
image = await db.get(MessageImage, envelope.image_id)
if image is None or image.room_id != envelope.room_id:
await websocket.send_json({"type": "error", "detail": "Invalid image"})
continue
image_id = image.id
message = await create_message(
db, envelope.room_id, user.id, envelope.content, image_id
)
await broadcast_new_message(db, broadcaster, presence, envelope.room_id, message, user)
elif envelope.type == "edit":