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