Add admin-configurable upload size limits

The 8MB image/file/avatar cap is now a site setting (UploadSettings,
single-row table like SmtpSettings) editable from the Admin Settings tab,
instead of a hardcoded constant. All three upload endpoints read the live
value and interpolate it into their 413 messages. A new GET
/api/uploads/limit endpoint (open to any authenticated user, unlike the
admin-only settings endpoints) lets the composer reject an oversized file
client-side before it ever hits the network, though the server still
enforces the same cap independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:44:05 -06:00
co-authored by Claude Sonnet 5
parent c78d7454b6
commit 62e4760c8a
19 changed files with 435 additions and 17 deletions
+2 -1
View File
@@ -11,7 +11,7 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import admin, auth, bots, health, push, rooms, signup, users, webhooks
from app.routers import admin, auth, bots, health, push, rooms, signup, uploads, users, webhooks
from app.ws.broadcaster import RoomBroadcaster
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -76,6 +76,7 @@ def create_app() -> FastAPI:
app.include_router(rooms.router)
app.include_router(users.router)
app.include_router(push.router)
app.include_router(uploads.router)
app.include_router(admin.router)
app.include_router(bots.router)
app.include_router(webhooks.router)
+2
View File
@@ -13,6 +13,7 @@ from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.site_invite import SiteInvite
from app.models.smtp_settings import SmtpSettings
from app.models.upload_settings import UploadSettings
from app.models.user import User
from app.models.webhook_incoming import WebhookIncoming
@@ -30,6 +31,7 @@ __all__ = [
"PasswordReset",
"SiteInvite",
"SmtpSettings",
"UploadSettings",
"PushSubscription",
"AdminAuditLog",
"ApiToken",
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class UploadSettings(Base):
"""A single-row table (enforced in the service layer, not the schema --
same convention as SmtpSettings) holding the site-wide max upload size
for images and file attachments, set through the Admin UI."""
__tablename__ = "upload_settings"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
max_upload_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
+23
View File
@@ -16,6 +16,7 @@ from app.schemas.admin import (
)
from app.schemas.site_invite import SiteInviteCreate, SiteInviteRead
from app.schemas.smtp_settings import SmtpSettingsRead, SmtpSettingsUpdate
from app.schemas.upload_settings import UploadSettingsRead, UploadSettingsUpdate
from app.schemas.webhook import EventSubscriptionAdminRead, WebhookIncomingAdminRead
from app.services.admin_service import (
CannotActOnSelfError,
@@ -40,6 +41,7 @@ from app.services.site_invite_service import (
revoke_site_invite,
)
from app.services.smtp_settings_service import get_smtp_settings, upsert_smtp_settings
from app.services.upload_settings_service import get_upload_settings, update_upload_settings
from app.services.webhook_service import (
list_all_event_subscriptions_admin,
list_all_incoming_webhooks_admin,
@@ -379,3 +381,24 @@ async def test_smtp_settings_endpoint(
raise HTTPException(status_code=400, detail="SMTP is not configured yet")
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to send test email: {exc}")
@router.get("/settings/uploads", response_model=UploadSettingsRead)
async def get_upload_settings_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await get_upload_settings(db)
return UploadSettingsRead(max_upload_bytes=cfg.max_upload_bytes, updated_at=cfg.updated_at)
@router.put("/settings/uploads", response_model=UploadSettingsRead)
async def update_upload_settings_endpoint(
data: UploadSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
require_site_admin(current_user)
cfg = await update_upload_settings(db, max_upload_bytes=data.max_upload_bytes)
return UploadSettingsRead(max_upload_bytes=cfg.max_upload_bytes, updated_at=cfg.updated_at)
+7 -2
View File
@@ -20,6 +20,7 @@ from app.services.password_service import (
request_password_reset,
validate_reset_token,
)
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.storage import (
ALLOWED_IMAGE_CONTENT_TYPES,
InvalidImageError,
@@ -89,10 +90,14 @@ async def upload_avatar(
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
upload_settings = await get_upload_settings(db)
try:
data = await read_capped(file)
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
raise HTTPException(
status_code=413,
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
)
try:
data, ext = process_image(
+13 -5
View File
@@ -35,6 +35,7 @@ from app.schemas.webhook import (
WebhookIncomingRead,
)
from app.services.message_service import get_reactions_for_messages, list_recent_messages
from app.services.upload_settings_service import format_mb, get_upload_settings
from app.services.room_service import (
AlreadyMemberError,
CannotRemoveOwnerError,
@@ -73,7 +74,6 @@ 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,
InvalidImageError,
UploadTooLargeError,
@@ -337,10 +337,14 @@ async def upload_room_image_endpoint(
if file.content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise HTTPException(status_code=400, detail="Unsupported image type")
upload_settings = await get_upload_settings(db)
try:
data = await read_capped(file)
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="Image exceeds 8 MB limit")
raise HTTPException(
status_code=413,
detail=f"Image exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
)
try:
data, ext = process_image(data, file.content_type)
@@ -388,10 +392,14 @@ async def upload_room_file_endpoint(
):
await require_room_member(room_id, current_user, db)
upload_settings = await get_upload_settings(db)
try:
data = await read_capped(file, cap=MAX_FILE_BYTES)
data = await read_capped(file, cap=upload_settings.max_upload_bytes)
except UploadTooLargeError:
raise HTTPException(status_code=413, detail="File exceeds 8 MB limit")
raise HTTPException(
status_code=413,
detail=f"File exceeds {format_mb(upload_settings.max_upload_bytes)} limit",
)
original_filename = file.filename or "file"
ext = pathlib.Path(original_filename).suffix
+22
View File
@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.upload_settings import UploadSettingsRead
from app.services.upload_settings_service import get_upload_settings
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
@router.get("/limit", response_model=UploadSettingsRead)
async def get_upload_limit_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Any authenticated user (not just site admins -- unlike
/api/admin/settings/uploads) can read the current cap, so the composer
can validate client-side before uploading."""
cfg = await get_upload_settings(db)
return UploadSettingsRead(max_upload_bytes=cfg.max_upload_bytes, updated_at=cfg.updated_at)
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from pydantic import BaseModel, Field
class UploadSettingsRead(BaseModel):
max_upload_bytes: int
updated_at: datetime
class UploadSettingsUpdate(BaseModel):
# Guardrails against a fat-fingered 0/negative value or an unbounded
# figure that could exhaust disk -- 1 MB to 500 MB is generous enough
# for any real attachment while still being a sane range to type into
# a number input.
max_upload_bytes: int = Field(ge=1024 * 1024, le=500 * 1024 * 1024)
@@ -0,0 +1,33 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import UploadSettings
from app.storage import DEFAULT_MAX_UPLOAD_BYTES
async def get_upload_settings(db: AsyncSession) -> UploadSettings:
"""Unlike SmtpSettings (where "no row yet" means "unconfigured, skip"),
an upload cap must always resolve to a usable value -- so this creates
the row with the default on first read instead of returning None."""
result = await db.execute(select(UploadSettings).limit(1))
settings_row = result.scalar_one_or_none()
if settings_row is None:
settings_row = UploadSettings(max_upload_bytes=DEFAULT_MAX_UPLOAD_BYTES)
db.add(settings_row)
await db.commit()
await db.refresh(settings_row)
return settings_row
async def update_upload_settings(db: AsyncSession, *, max_upload_bytes: int) -> UploadSettings:
settings_row = await get_upload_settings(db)
settings_row.max_upload_bytes = max_upload_bytes
await db.commit()
await db.refresh(settings_row)
return settings_row
def format_mb(num_bytes: int) -> str:
"""Used in 413 error messages, which need to reflect the live
admin-configured cap rather than a hardcoded "8 MB"."""
return f"{num_bytes / (1024 * 1024):g} MB"
+5 -6
View File
@@ -10,11 +10,10 @@ from PIL import Image, UnidentifiedImageError
# production layout with zero new config.
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
# Seed value for the admin-configurable UploadSettings row (see
# app/services/upload_settings_service.py) -- also the fallback `read_capped`
# default for call sites that don't look up the live setting.
DEFAULT_MAX_UPLOAD_BYTES = 8 * 1024 * 1024
_READ_CHUNK_BYTES = 1024 * 1024
_MAX_DIMENSION = 2000
@@ -35,7 +34,7 @@ class InvalidImageError(Exception):
pass
async def read_capped(file, cap: int = MAX_IMAGE_BYTES) -> bytes:
async def read_capped(file, cap: int = DEFAULT_MAX_UPLOAD_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."""