feat(storage): remove upload-size cap, add MB/GB unit toggle

Storage panel's max-upload-size input now accepts any positive integer
(bounded only by JS safe-integer ceiling) and offers an MB/GB unit
toggle. Multipart limit relaxed to MAX_SAFE_INTEGER — actual cap is
enforced per-request from the DB setting, not at the framework layer.
This commit is contained in:
Jannis Braun
2026-04-28 18:37:16 +02:00
parent 213fbc943b
commit 76b6621d62
4 changed files with 68 additions and 19 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ Settings are split into two API surfaces:
| discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page | | discoveryEnabled | boolean | discoveryEnabled | boolean | Controls space Explore page |
| gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security | | gifApiKey | string? | gifApiKey | string or empty to clear | Returned masked as `****{last4}` for security |
| gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null | | gifEnabled | boolean? | (derived) | -- | Read-only; true when gifApiKey is non-null |
| maxUploadSizeMb | number | maxUploadSizeBytes | 1-5120 MB | Stored as bytes; converted on read/write. DB null = use env `MAX_UPLOAD_SIZE` (default 100MB) | | maxUploadSizeMb | number | maxUploadSizeBytes | positive integer (>=1 MB), upper bound is JS safe-integer ceiling (~8 EB) — effectively unlimited | Operator-set; no application cap. Stored as bytes; converted on read/write. DB null = use env `MAX_UPLOAD_SIZE` (default 100MB) |
| federationRelayEnabled | boolean | federationRelayEnabled | boolean | Default: 1 (enabled) | | federationRelayEnabled | boolean | federationRelayEnabled | boolean | Default: 1 (enabled) |
| federationRelayTtlDays | number | federationRelayTtlDays | integer 1-365 | Default: 30 days | | federationRelayTtlDays | number | federationRelayTtlDays | integer 1-365 | Default: 30 days |
| autoAcceptPeering | boolean | autoAcceptPeering | boolean | Default: true. When false, `peer/accept` rejects unsolicited requests (403 PEERING_REQUIRES_APPROVAL); only requests where a local `pending` record already exists are accepted | | autoAcceptPeering | boolean | autoAcceptPeering | boolean | Default: true. When false, `peer/accept` rejects unsolicited requests (403 PEERING_REQUIRES_APPROVAL); only requests where a local `pending` record already exists are accepted |
+1 -1
View File
@@ -65,7 +65,7 @@ async function main(): Promise<void> {
await app.register(multipart, { await app.register(multipart, {
limits: { limits: {
fileSize: 5 * 1024 * 1024 * 1024, // 5GB hard ceiling — actual limit enforced per-request from DB fileSize: Number.MAX_SAFE_INTEGER, // No global cap — actual limit enforced per-request from DB
}, },
}); });
+4 -3
View File
@@ -233,10 +233,11 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
if (body.maxUploadSizeMb !== undefined) { if (body.maxUploadSizeMb !== undefined) {
const mb = Number(body.maxUploadSizeMb); const mb = Number(body.maxUploadSizeMb);
if (isNaN(mb) || mb < 1 || mb > 5120) { const MAX_MB = Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024));
return reply.code(400).send({ error: 'maxUploadSizeMb must be between 1 and 5120', statusCode: 400 }); if (!Number.isFinite(mb) || !Number.isInteger(mb) || mb < 1 || mb > MAX_MB) {
return reply.code(400).send({ error: `maxUploadSizeMb must be a positive integer (1 - ${MAX_MB})`, statusCode: 400 });
} }
updateData.maxUploadSizeBytes = Math.round(mb * 1024 * 1024); updateData.maxUploadSizeBytes = mb * 1024 * 1024;
} }
if (body.federationRelayEnabled !== undefined) { if (body.federationRelayEnabled !== undefined) {
@@ -12,6 +12,22 @@ function formatBytes(bytes: number): string {
return `${value < 10 ? value.toFixed(2) : value < 100 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; return `${value < 10 ? value.toFixed(2) : value < 100 ? value.toFixed(1) : Math.round(value)} ${units[i]}`;
} }
type UploadUnit = 'MB' | 'GB';
// Render a number with up to 3 decimals, trimming trailing zeros. 1.5 → "1.5", 5 → "5", 0.098 → "0.098".
function formatUnitValue(n: number): string {
return Number(n.toFixed(3)).toString();
}
function parseDisplayMb(input: string, unit: UploadUnit): number | null {
const trimmed = input.trim();
if (trimmed === '') return null;
const n = Number(trimmed);
if (!Number.isFinite(n) || n <= 0) return null;
const mb = unit === 'GB' ? Math.round(n * 1024) : Math.round(n);
return Number.isInteger(mb) && mb >= 1 ? mb : null;
}
export function StoragePanel() { export function StoragePanel() {
const [stats, setStats] = useState<StorageStats | null>(null); const [stats, setStats] = useState<StorageStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -24,9 +40,9 @@ export function StoragePanel() {
// Upload limit state // Upload limit state
const instanceSettings = useSettingsStore((s) => s.instanceSettings); const instanceSettings = useSettingsStore((s) => s.instanceSettings);
const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings); const updateInstanceSettings = useSettingsStore((s) => s.updateInstanceSettings);
const [uploadLimitMb, setUploadLimitMb] = useState<number>(100); const [uploadUnit, setUploadUnit] = useState<UploadUnit>('MB');
const [uploadLimitInput, setUploadLimitInput] = useState<string>('100');
const [uploadLimitSaving, setUploadLimitSaving] = useState(false); const [uploadLimitSaving, setUploadLimitSaving] = useState(false);
const [uploadLimitDirty, setUploadLimitDirty] = useState(false);
// Media retention state // Media retention state
const [mediaAgeDays, setMediaAgeDays] = useState(90); const [mediaAgeDays, setMediaAgeDays] = useState(90);
@@ -52,17 +68,34 @@ export function StoragePanel() {
}, [fetchStats]); }, [fetchStats]);
useEffect(() => { useEffect(() => {
if (instanceSettings?.maxUploadSizeMb) { const mb = instanceSettings?.maxUploadSizeMb;
setUploadLimitMb(instanceSettings.maxUploadSizeMb); if (typeof mb === 'number' && mb > 0) {
const unit: UploadUnit = mb >= 1024 ? 'GB' : 'MB';
setUploadUnit(unit);
setUploadLimitInput(unit === 'GB' ? formatUnitValue(mb / 1024) : String(mb));
} }
}, [instanceSettings]); }, [instanceSettings]);
const parsedUploadMb = parseDisplayMb(uploadLimitInput, uploadUnit);
const uploadLimitDirty = parsedUploadMb !== null && parsedUploadMb !== instanceSettings?.maxUploadSizeMb;
const handleUploadUnitChange = (next: UploadUnit) => {
if (next === uploadUnit) return;
const n = Number(uploadLimitInput.trim());
if (Number.isFinite(n) && n > 0) {
setUploadLimitInput(next === 'GB' ? formatUnitValue(n / 1024) : String(Math.round(n * 1024)));
}
setUploadUnit(next);
};
const handleUploadLimitSave = async () => { const handleUploadLimitSave = async () => {
if (parsedUploadMb === null) return;
const mb = parsedUploadMb;
setUploadLimitSaving(true); setUploadLimitSaving(true);
try { try {
await updateInstanceSettings({ maxUploadSizeMb: uploadLimitMb }); await updateInstanceSettings({ maxUploadSizeMb: mb });
setUploadLimitDirty(false); const display = mb >= 1024 ? `${formatUnitValue(mb / 1024)} GB` : `${mb} MB`;
addToast(`Upload limit set to ${uploadLimitMb} MB`, 'success'); addToast(`Upload limit set to ${display}`, 'success');
} catch { } catch {
addToast('Failed to update upload limit', 'warning'); addToast('Failed to update upload limit', 'warning');
} finally { } finally {
@@ -199,16 +232,31 @@ export function StoragePanel() {
<label className="text-sm text-txt-secondary whitespace-nowrap">Max file size</label> <label className="text-sm text-txt-secondary whitespace-nowrap">Max file size</label>
<input <input
type="number" type="number"
min={1} min={uploadUnit === 'GB' ? 0.001 : 1}
max={5120} step={uploadUnit === 'GB' ? 0.5 : 1}
value={uploadLimitMb} value={uploadLimitInput}
onChange={(e) => { setUploadLimitMb(Number(e.target.value)); setUploadLimitDirty(true); }} onChange={(e) => setUploadLimitInput(e.target.value)}
className="input-standard w-20 px-2 py-1 text-sm text-center" className="input-standard w-24 px-2 py-1 text-sm text-center"
/> />
<span className="text-sm text-txt-tertiary">MB</span> <div className="flex items-center gap-0.5 rounded-lg bg-surface-input p-0.5">
{(['MB', 'GB'] as const).map((u) => (
<button
key={u}
type="button"
onClick={() => handleUploadUnitChange(u)}
className={`px-2.5 py-1 rounded text-[12px] font-medium transition-colors ${
uploadUnit === u
? 'bg-accent-primary text-white'
: 'text-txt-tertiary hover:text-txt-primary'
}`}
>
{u}
</button>
))}
</div>
<button <button
onClick={handleUploadLimitSave} onClick={handleUploadLimitSave}
disabled={!uploadLimitDirty || uploadLimitSaving || uploadLimitMb < 1 || uploadLimitMb > 5120} disabled={!uploadLimitDirty || uploadLimitSaving || parsedUploadMb === null}
className="px-3 py-1 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 ml-auto" className="px-3 py-1 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 ml-auto"
> >
{uploadLimitSaving ? 'Saving...' : 'Save'} {uploadLimitSaving ? 'Saving...' : 'Save'}