Private
Public Access
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:
@@ -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
|
||||
Reference in New Issue
Block a user