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:
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { UploadSettings } from '../types'
|
||||
|
||||
export function getUploadLimit(): Promise<UploadSettings> {
|
||||
return apiFetch<UploadSettings>('/api/uploads/limit')
|
||||
}
|
||||
@@ -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/')) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -225,3 +225,8 @@ export interface SmtpSettings {
|
||||
use_tls: boolean
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UploadSettings {
|
||||
max_upload_bytes: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user