Private
Public Access
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:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user