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
+29 -1
View File
@@ -1,4 +1,4 @@
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, emoji & reactions, user profiles, site invites & email, password reset)
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8, image uploads, file attachments, admin-configurable upload size limits, emoji & reactions, user profiles, site invites & email, password reset)
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
CRUD (open and private), room roles (owner/admin/member) and direct
@@ -398,6 +398,34 @@ share them; `ImageTooLargeError` was likewise renamed to
Same orphaned-upload disk-space caveat as images applies here too.
## Upload size limits
The 8 MB image/file/avatar cap is no longer hardcoded — it's an
admin-configurable site setting (`UploadSettings`, single-row table, same
"fetch-or-create" convention as `SmtpSettings`), editable from the Admin
portal's Settings tab. Unlike `SmtpSettings` (where "no row yet" means
"unconfigured, skip"), a missing row here still needs a usable value, so
`upload_settings_service.get_upload_settings` creates it with the 8 MB
default (`storage.DEFAULT_MAX_UPLOAD_BYTES`) on first read instead of
returning `None`.
- `GET`/`PUT /api/admin/settings/uploads` (site-admin only) — read/update
the cap. Bounds-checked to 1500 MB (`UploadSettingsUpdate`) to guard
against a fat-fingered 0 or an unbounded figure that could exhaust disk.
- `GET /api/uploads/limit` — unlike the admin endpoints, this one is open
to any authenticated user (same `Depends(get_current_user)`-only pattern
as `/api/push/vapid-public-key`), since every room member needs to know
the cap, not just admins. The frontend composer fetches it once per
mount and rejects an oversized file client-side before ever hitting the
network; the server still enforces the same value independently via
`read_capped(file, cap=...)`, so the client-side check is a UX nicety,
not the actual security boundary.
- All three upload endpoints (room image, room file, avatar) now call
`get_upload_settings(db)` and pass the live value into `read_capped`
instead of relying on a module-level constant; their 413 error messages
interpolate the actual configured limit (`upload_settings_service.
format_mb`) rather than a hardcoded "8 MB" string.
## Emoji & reactions
An emoji picker in the frontend composer is purely client-side (a static
@@ -0,0 +1,37 @@
"""upload settings
Revision ID: 880f080648de
Revises: 5b1a142dc271
Create Date: 2026-08-15 20:35:19.357542
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '880f080648de'
down_revision: Union[str, Sequence[str], None] = '5b1a142dc271'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('upload_settings',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('max_upload_bytes', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('upload_settings')
# ### end Alembic commands ###
+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."""
+113
View File
@@ -0,0 +1,113 @@
import uuid
from sqlalchemy import select
from app.models import UploadSettings, User
from tests.conftest import register_and_login
async def _get_settings_row(db_session) -> UploadSettings:
result = await db_session.execute(select(UploadSettings))
return result.scalar_one()
async def _make_admin(db_session, user_id: str) -> None:
user = await db_session.get(User, uuid.UUID(user_id))
user.is_site_admin = True
await db_session.commit()
async def test_upload_settings_require_admin(client, db_session):
await register_and_login(client, db_session, username="alice")
resp = await client.get("/api/admin/settings/uploads")
assert resp.status_code == 403
resp = await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 1024 * 1024})
assert resp.status_code == 403
async def test_upload_settings_get_defaults_to_8mb(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.get("/api/admin/settings/uploads")
assert resp.status_code == 200
assert resp.json()["max_upload_bytes"] == 8 * 1024 * 1024
async def test_upload_settings_update(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 20 * 1024 * 1024})
assert resp.status_code == 200
assert resp.json()["max_upload_bytes"] == 20 * 1024 * 1024
resp = await client.get("/api/admin/settings/uploads")
assert resp.json()["max_upload_bytes"] == 20 * 1024 * 1024
row = await _get_settings_row(db_session)
assert row.max_upload_bytes == 20 * 1024 * 1024
async def test_upload_settings_rejects_out_of_range(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
resp = await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 0})
assert resp.status_code == 422
resp = await client.put(
"/api/admin/settings/uploads", json={"max_upload_bytes": 1000 * 1024 * 1024}
)
assert resp.status_code == 422
async def test_upload_limit_endpoint_accessible_to_any_authenticated_user(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 5 * 1024 * 1024})
await register_and_login(client, db_session, username="regular")
resp = await client.get("/api/uploads/limit")
assert resp.status_code == 200
assert resp.json()["max_upload_bytes"] == 5 * 1024 * 1024
async def test_lowering_limit_enforced_on_file_upload(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 1024 * 1024})
room = (await client.post("/api/rooms", json={"name": "general"})).json()
resp = await client.post(
f"/api/rooms/{room['id']}/files",
files={"file": ("big.bin", b"0" * (2 * 1024 * 1024), "application/octet-stream")},
)
assert resp.status_code == 413
assert "1 MB" in resp.json()["detail"]
resp = await client.post(
f"/api/rooms/{room['id']}/files",
files={"file": ("small.bin", b"0" * (512 * 1024), "application/octet-stream")},
)
assert resp.status_code == 201
async def test_raising_limit_allows_larger_image_upload(client, db_session):
admin = await register_and_login(client, db_session, username="admin1")
await _make_admin(db_session, admin["id"])
await client.put("/api/admin/settings/uploads", json={"max_upload_bytes": 20 * 1024 * 1024})
room = (await client.post("/api/rooms", json={"name": "general"})).json()
# 9MB would be rejected under the old hardcoded 8MB cap.
oversized_for_old_cap = b"0" * (9 * 1024 * 1024)
resp = await client.post(
f"/api/rooms/{room['id']}/images",
files={"file": ("big.bin", oversized_for_old_cap, "image/png")},
)
# Not a valid PNG, but it must get past the size check to prove the
# raised limit took effect -- 400 (invalid image), not 413.
assert resp.status_code == 400
+12
View File
@@ -6,6 +6,7 @@ import type {
EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
UploadSettings,
WebhookIncomingAdmin,
} from '../types'
@@ -105,3 +106,14 @@ export function updateSmtpSettings(payload: SmtpSettingsPayload): Promise<SmtpSe
export function sendTestSmtpEmail(): Promise<void> {
return apiFetch<void>('/api/admin/settings/smtp/test', { method: 'POST' })
}
export function getUploadSettings(): Promise<UploadSettings> {
return apiFetch<UploadSettings>('/api/admin/settings/uploads')
}
export function updateUploadSettings(maxUploadBytes: number): Promise<UploadSettings> {
return apiFetch<UploadSettings>('/api/admin/settings/uploads', {
method: 'PUT',
body: JSON.stringify({ max_upload_bytes: maxUploadBytes }),
})
}
+6
View File
@@ -0,0 +1,6 @@
import { apiFetch } from './client'
import type { UploadSettings } from '../types'
export function getUploadLimit(): Promise<UploadSettings> {
return apiFetch<UploadSettings>('/api/uploads/limit')
}
+18 -1
View File
@@ -1,6 +1,7 @@
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import { useOnlineStatus } from '../hooks/useOnlineStatus'
import { uploadRoomFile, uploadRoomImage } from '../api/rooms'
import { getUploadLimit } from '../api/uploads'
import { EmojiPicker } from './EmojiPicker'
import './Composer.css'
@@ -26,10 +27,20 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null)
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false)
const [maxUploadBytes, setMaxUploadBytes] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const online = useOnlineStatus()
useEffect(() => {
getUploadLimit()
.then((limit) => setMaxUploadBytes(limit.max_upload_bytes))
.catch(() => {
// Non-critical -- if this fails, oversized uploads just get caught
// by the server's 413 instead of client-side, no functional loss.
})
}, [])
function autoGrow() {
const el = textareaRef.current
if (!el) return
@@ -60,6 +71,12 @@ export function Composer({ roomId, roomName, disabled, onSend }: ComposerProps)
if (!file) return
setUploadError(null)
if (maxUploadBytes !== null && file.size > maxUploadBytes) {
setUploadError(`File exceeds ${formatFileSize(maxUploadBytes)} limit`)
return
}
setUploading(true)
try {
if (file.type.startsWith('image/')) {
+6
View File
@@ -338,3 +338,9 @@
color: var(--ds-muted);
margin: 0;
}
.admin-settings-hint {
font-size: 0.82rem;
color: var(--ds-muted);
margin: -4px 0 0;
}
+65 -1
View File
@@ -5,6 +5,7 @@ import {
deactivateUser,
demoteUser,
getSmtpSettings,
getUploadSettings,
inviteUser,
listAdminRooms,
listAdminUsers,
@@ -20,6 +21,7 @@ import {
transferOwnershipAdmin,
unarchiveRoom,
updateSmtpSettings,
updateUploadSettings,
} from '../api/admin'
import { ApiError } from '../api/client'
import { createApiToken, createBot, listApiTokens, listBots, revokeApiToken } from '../api/bots'
@@ -37,6 +39,7 @@ import type {
EventSubscriptionAdmin,
SiteInvite,
SmtpSettings,
UploadSettings,
WebhookIncomingAdmin,
} from '../types'
import { TopBar } from '../components/TopBar'
@@ -84,6 +87,11 @@ export function AdminPage() {
const [smtpTestBusy, setSmtpTestBusy] = useState(false)
const [smtpTestResult, setSmtpTestResult] = useState<string | null>(null)
const [uploadSettings, setUploadSettings] = useState<UploadSettings | null>(null)
const [uploadLoaded, setUploadLoaded] = useState(false)
const [uploadMaxMb, setUploadMaxMb] = useState('8')
const [uploadSaving, setUploadSaving] = useState(false)
function reportError(err: unknown) {
setError(err instanceof ApiError ? err.message : String(err))
}
@@ -134,6 +142,16 @@ export function AdminPage() {
.catch(reportError)
}
function loadUploadSettings() {
getUploadSettings()
.then((cfg) => {
setUploadSettings(cfg)
setUploadLoaded(true)
setUploadMaxMb(String(cfg.max_upload_bytes / (1024 * 1024)))
})
.catch(reportError)
}
useEffect(() => {
if (tab === 'users') {
loadUsers()
@@ -148,7 +166,10 @@ export function AdminPage() {
loadWebhooksAdmin()
}
if (tab === 'audit') loadAuditLog()
if (tab === 'settings') loadSmtpSettings()
if (tab === 'settings') {
loadSmtpSettings()
loadUploadSettings()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab])
@@ -316,6 +337,21 @@ export function AdminPage() {
}
}
async function handleSaveUploadSettings(e: FormEvent) {
e.preventDefault()
setUploadSaving(true)
setError(null)
try {
const updated = await updateUploadSettings(Math.round(Number(uploadMaxMb) * 1024 * 1024))
setUploadSettings(updated)
setUploadMaxMb(String(updated.max_upload_bytes / (1024 * 1024)))
} catch (err) {
reportError(err)
} finally {
setUploadSaving(false)
}
}
return (
<div className="admin-page">
<TopBar />
@@ -775,6 +811,34 @@ export function AdminPage() {
{smtpTestResult && <p className="admin-settings-test-result">{smtpTestResult}</p>}
</form>
)}
<h2 className="admin-subheading">Uploads</h2>
{!uploadLoaded && <p className="admin-placeholder">Loading…</p>}
{uploadLoaded && (
<form className="admin-settings-form" onSubmit={handleSaveUploadSettings}>
<label className="admin-settings-field admin-settings-field-narrow">
Max attachment size (MB)
<input
type="number"
value={uploadMaxMb}
onChange={(e) => setUploadMaxMb(e.target.value)}
min={1}
max={500}
step={1}
required
/>
</label>
<p className="admin-settings-hint">
Applies to message images, message file attachments, and avatars. Current limit:{' '}
{uploadSettings ? `${uploadSettings.max_upload_bytes / (1024 * 1024)} MB` : ''}.
</p>
<div className="admin-settings-actions">
<button type="submit" className="btn-primary" disabled={uploadSaving}>
{uploadSaving ? 'Saving' : 'Save'}
</button>
</div>
</form>
)}
</>
)}
</div>
+5
View File
@@ -225,3 +225,8 @@ export interface SmtpSettings {
use_tls: boolean
updated_at: string
}
export interface UploadSettings {
max_upload_bytes: number
updated_at: string
}