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