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
+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,