feat(library): use configurable max upload size limit

Replace the hardcoded 500MB limit with a dynamic value from the application
settings to allow uploading files larger than 500MB.

Closes #550
This commit is contained in:
neon
2026-07-04 18:43:18 +00:00
parent 0c7817621b
commit 7f28b40d1f
24 changed files with 204 additions and 31 deletions
@@ -2,9 +2,10 @@ import { ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { BookDockFile } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
@@ -22,7 +23,9 @@ const CONCURRENCY = 3
function validateFile(file: File): string | null {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!SUPPORTED_FORMATS.includes(ext)) return `Unsupported type .${ext}`
if (file.size > MAX_UPLOAD_BYTES) return 'File exceeds 500 MB limit'
const { maxUploadSizeMb } = useAppInfo()
const limitBytes = maxUploadSizeMb.value * 1024 * 1024
if (file.size > limitBytes) return `File exceeds ${maxUploadSizeMb.value} MB limit`
return null
}
@@ -4,6 +4,7 @@ import { CheckCircle2, FileUp, Loader2, Plus, Upload, X, XCircle, RotateCcw } fr
import { Permission } from '@bookorbit/types'
import { SUPPORTED_FORMATS_ACCEPT, useAddBookFile } from '@/features/book/composables/useAddBookFile'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
const props = defineProps<{
bookId: number
@@ -15,6 +16,7 @@ const emit = defineEmits<{
}>()
const { hasPermission } = usePermissions()
const { maxUploadSizeMb } = useAppInfo()
const isDragging = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
@@ -170,7 +172,7 @@ function formatPillClass(filename: string): string {
</div>
<div>
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
<p class="text-xs text-muted-foreground mt-0.5">epub, pdf, mobi, cbz, m4b and more - up to 500 MB each</p>
<p class="text-xs text-muted-foreground mt-0.5">epub, pdf, mobi, cbz, m4b and more - up to {{ maxUploadSizeMb }} MB each</p>
</div>
</div>
@@ -2,7 +2,7 @@
import { ref, watch, computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { BookOpen, Download, Eye, FilePlus, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from '@lucide/vue'
import { BookOpen, Download, Eye, FilePlus, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical, Pencil, Trash2 } from '@lucide/vue'
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
import { Permission, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -4,6 +4,12 @@ vi.mock('@/lib/api', () => ({
getAccessToken: vi.fn<() => string>().mockReturnValue('test-token'),
}))
vi.mock('@/features/settings/composables/useAppInfo', () => ({
useAppInfo: () => ({
maxUploadSizeMb: { value: 500 },
}),
}))
interface MockXhrInstance {
open: ReturnType<typeof vi.fn>
setRequestHeader: ReturnType<typeof vi.fn>
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { AddBookFileResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
@@ -25,8 +26,10 @@ function validateFile(file: File): string | null {
if (!SUPPORTED_FORMATS.includes(ext)) {
return `Unsupported type .${ext}. Allowed: ${SUPPORTED_FORMATS.join(', ')}`
}
if (file.size > MAX_FILE_BYTES) {
return `File exceeds the 500 MB limit`
const { maxUploadSizeMb } = useAppInfo()
const limitBytes = maxUploadSizeMb.value * 1024 * 1024
if (file.size > limitBytes) {
return `File exceeds the ${maxUploadSizeMb.value} MB limit`
}
return null
}
@@ -2,8 +2,8 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { CheckCircle2, FileUp, Loader2, Plus, RotateCcw, Upload, X, XCircle } from '@lucide/vue'
import { api } from '@/lib/api'
import type { Library } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload, type FileUploadStatus } from '../composables/useBookUpload'
import { emitLibraryUploadCompleted } from '../composables/useLibraryUploadEvents'
import { useLibraries } from '../composables/useLibraries'
@@ -19,6 +19,7 @@ const emit = defineEmits<{
const router = useRouter()
const { libraries, fetchLibraries } = useLibraries()
const { maxUploadSizeMb } = useAppInfo()
const selectedLibraryId = ref<number | undefined>(props.libraryId)
const folders = ref<Library['folders']>([])
@@ -254,7 +255,7 @@ function formatPillClass(filename: string): string {
</div>
<div>
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ SUPPORTED_FORMATS.join(', ') }} - up to 500 MB each</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ SUPPORTED_FORMATS.join(', ') }} - up to {{ maxUploadSizeMb }} MB each</p>
</div>
</div>
@@ -326,7 +327,7 @@ function formatPillClass(filename: string): string {
<!-- Actions -->
<div class="shrink-0 flex items-center gap-0.5">
<button
v-if="item.status === 'error'"
v-if="item.status === 'error' && !item.validationError"
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/85 hover:text-primary hover:bg-primary/10 transition-colors"
title="Retry"
@click="retryFile(item.id)"
@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { UploadResult } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
@@ -15,6 +16,7 @@ export interface FileUploadItem {
progress: number
error?: string
bookId?: number
validationError?: boolean
}
const UPLOAD_CONCURRENCY = 3
@@ -24,8 +26,10 @@ function validateFile(file: File): string | null {
if (!SUPPORTED_FORMATS.includes(ext)) {
return `Unsupported type .${ext}. Allowed: ${SUPPORTED_FORMATS.join(', ')}`
}
if (file.size > MAX_UPLOAD_BYTES) {
return `File exceeds the 500 MB limit`
const { maxUploadSizeMb } = useAppInfo()
const limitBytes = maxUploadSizeMb.value * 1024 * 1024
if (file.size > limitBytes) {
return `File exceeds the ${maxUploadSizeMb.value} MB limit`
}
return null
}
@@ -97,6 +101,7 @@ export function useBookUpload() {
status: error ? 'error' : 'pending',
progress: 0,
error,
validationError: error !== undefined,
})
}
}
@@ -107,7 +112,7 @@ export function useBookUpload() {
function retryFile(id: string) {
const item = files.value.find((f) => f.id === id)
if (!item || item.status !== 'error') return
if (!item || item.status !== 'error' || item.validationError) return
item.status = 'pending'
item.error = undefined
item.progress = 0
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award } from '@lucide/vue'
import { Check, RefreshCw, Sparkles, ArrowUpFromLine, CheckCircle2, AlertCircle, Loader2, Bell, Award, Upload } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -22,6 +22,9 @@ const migrationLoading = ref(true)
const updateCheckEnabled = ref(true)
const updateCheckLoading = ref(false)
const maxUploadSizeMb = ref(500)
const localMaxUploadSizeMb = ref(500)
const maxUploadSizeSaving = ref(false)
const achievementBackfillRunning = ref(false)
const achievementBackfillError = ref<string | null>(null)
const achievementBackfillResult = ref<{ usersProcessed: number; awardsGranted: number } | null>(null)
@@ -53,6 +56,12 @@ onMounted(async () => {
const settings: { key: string; value: string }[] = await res.json()
const row = settings.find((s) => s.key === 'update_check_enabled')
if (row) updateCheckEnabled.value = row.value === 'true'
const uploadRow = settings.find((s) => s.key === 'max_upload_size_mb')
if (uploadRow) {
const val = parseInt(uploadRow.value, 10) || 500
maxUploadSizeMb.value = val
localMaxUploadSizeMb.value = val
}
}
} catch {
// non-fatal
@@ -137,6 +146,32 @@ async function runAchievementBackfill() {
}
}
async function saveMaxUploadSize() {
const val = localMaxUploadSizeMb.value
if (isNaN(val) || val <= 0) {
toast.error('Upload size limit must be greater than 0')
return
}
maxUploadSizeSaving.value = true
try {
const res = await api('/api/v1/app-settings/max_upload_size_mb', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(val) }),
})
if (res.ok) {
maxUploadSizeMb.value = val
toast.success('Upload size limit updated')
} else {
toast.error('Failed to update setting')
}
} catch {
toast.error('Failed to update setting')
} finally {
maxUploadSizeSaving.value = false
}
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
@@ -161,6 +196,44 @@ function formatDate(iso: string | null | undefined): string {
</div>
<div class="mt-5 md:mt-0 space-y-6">
<!-- Uploads -->
<div v-if="isSuperuser">
<p class="settings-group-label">Uploads</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border shadow-xs">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Upload :size="16" class="text-primary" />
</div>
<div class="min-w-0">
<p class="settings-label">Maximum upload file size limit</p>
<p class="settings-hint">Configure the maximum size limit for file uploads across the system.</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<input
type="number"
v-model.number="localMaxUploadSizeMb"
min="1"
max="10000"
class="w-24 rounded-md border border-input bg-background px-3 py-1.5 text-sm text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
:disabled="maxUploadSizeSaving"
/>
<span class="text-sm text-muted-foreground">MB</span>
</div>
<button
class="settings-btn-outline"
:disabled="maxUploadSizeSaving || localMaxUploadSizeMb === maxUploadSizeMb"
@click="saveMaxUploadSize"
>
Save
</button>
</div>
</div>
</div>
</div>
<!-- Booklore Import -->
<div>
<p class="settings-group-label">Import</p>
@@ -8,6 +8,7 @@ const version = ref('')
const updateAvailable = ref<boolean | null>(null)
const latestVersion = ref<string | null>(null)
const bookDockPath = ref('')
const maxUploadSizeMb = ref(500)
const isLoading = ref(false)
const error = ref<string | null>(null)
@@ -24,6 +25,7 @@ export function useAppInfo() {
updateAvailable.value = data.updateAvailable
latestVersion.value = data.latestVersion
bookDockPath.value = data.bookDockPath ?? ''
maxUploadSizeMb.value = data.maxUploadSizeMb ?? 500
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load app info'
} finally {
@@ -31,5 +33,5 @@ export function useAppInfo() {
}
}
return { version, updateAvailable, latestVersion, bookDockPath, isLoading, error, loadAppInfo }
return { version, updateAvailable, latestVersion, bookDockPath, maxUploadSizeMb, isLoading, error, loadAppInfo }
}
+1
View File
@@ -3,4 +3,5 @@ export interface AppInfoResponse {
updateAvailable: boolean | null;
latestVersion: string | null;
bookDockPath: string;
maxUploadSizeMb: number;
}
@@ -24,6 +24,7 @@ export const APP_SETTING_KEYS = {
AUDIT_RETENTION_DAYS: 'audit_retention_days',
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
UPDATE_CHECK_ENABLED: 'update_check_enabled',
MAX_UPLOAD_SIZE_MB: 'max_upload_size_mb',
} as const;
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
@@ -18,6 +18,7 @@ function makeConfig(version = 'v1.2.3', bookDockPath = '/data/book-dock'): jest.
function makeAppSettings(enabled = true): jest.Mocked<AppSettingsService> {
return {
isUpdateCheckEnabled: vi.fn().mockResolvedValue(enabled),
getMaxUploadSizeMb: vi.fn().mockResolvedValue(500),
} as unknown as jest.Mocked<AppSettingsService>;
}
@@ -80,6 +81,7 @@ describe('AppInfoService', () => {
updateAvailable: null,
latestVersion: null,
bookDockPath: '/app/data/book-dock',
maxUploadSizeMb: 500,
});
});
@@ -46,11 +46,14 @@ export class AppInfoService implements OnApplicationBootstrap {
}
}
const maxUploadSizeMb = await this.appSettingsService.getMaxUploadSizeMb();
return {
version,
updateAvailable: this.updateAvailable,
latestVersion: this.latestVersion,
bookDockPath,
maxUploadSizeMb,
};
}
@@ -526,4 +526,38 @@ describe('AppSettingsService', () => {
expect(await service.isUpdateCheckEnabled()).toBe(true);
});
});
describe('getMaxUploadSizeMb', () => {
it('returns 500 by default when setting is absent', async () => {
repo.findByKey.mockResolvedValue(undefined);
expect(await service.getMaxUploadSizeMb()).toBe(500);
});
it('returns parsed integer value when valid', async () => {
repo.findByKey.mockResolvedValue({ key: 'max_upload_size_mb', value: '1024' } as never);
expect(await service.getMaxUploadSizeMb()).toBe(1024);
});
it('returns 500 when value is invalid or <= 0', async () => {
repo.findByKey.mockResolvedValue({ key: 'max_upload_size_mb', value: '-50' } as never);
expect(await service.getMaxUploadSizeMb()).toBe(500);
});
});
describe('update max_upload_size_mb validation', () => {
it('allows valid positive integer', async () => {
const setting = { key: 'max_upload_size_mb', value: '1000' };
repo.updateByKey.mockResolvedValue(setting as never);
const result = await service.update('max_upload_size_mb', '1000');
expect(result).toEqual(setting);
expect(repo.updateByKey).toHaveBeenCalledWith('max_upload_size_mb', '1000');
});
it('throws BadRequestException for invalid integer', async () => {
await expect(service.update('max_upload_size_mb', 'invalid')).rejects.toThrow(BadRequestException);
await expect(service.update('max_upload_size_mb', '-10')).rejects.toThrow(BadRequestException);
await expect(service.update('max_upload_size_mb', '0')).rejects.toThrow(BadRequestException);
expect(repo.updateByKey).not.toHaveBeenCalled();
});
});
});
@@ -62,6 +62,12 @@ export class AppSettingsService {
}
async update(key: string, value: string) {
if (key === APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB) {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed <= 0) {
throw new BadRequestException('Upload size limit must be an integer greater than 0');
}
}
const setting = await this.repo.updateByKey(key, value);
if (!setting) throw new NotFoundException(`Setting '${key}' not found`);
return setting;
@@ -278,6 +284,12 @@ export class AppSettingsService {
const row = await this.repo.findByKey(APP_SETTING_KEYS.UPDATE_CHECK_ENABLED);
return parseBooleanSetting(row?.value, true);
}
async getMaxUploadSizeMb(): Promise<number> {
const row = await this.repo.findByKey(APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB);
const size = row?.value ? parseInt(row.value, 10) : 500;
return isNaN(size) || size <= 0 ? 500 : size;
}
}
function parseAutoFinalizeMetadataMode(value: string | undefined): BookDockAutoFinalizeMetadataMode {
@@ -39,6 +39,7 @@ function makeController() {
const finalizeService = { previewNames: vi.fn(), finalize: vi.fn() };
const watcherService = { rescan: vi.fn() };
const repo = { findById: vi.fn() };
const appSettings = { getMaxUploadSizeMb: vi.fn().mockResolvedValue(500) };
const controller = new BookDockController(
service as never,
@@ -46,9 +47,10 @@ function makeController() {
finalizeService as never,
watcherService as never,
repo as never,
appSettings as never,
);
return { controller, service, ingestService, finalizeService, watcherService, repo };
return { controller, service, ingestService, finalizeService, watcherService, repo, appSettings };
}
const MOCK_USER = { id: 1, isSuperuser: false } as any;
@@ -47,7 +47,7 @@ import {
PreviewNamesDto,
SelectionSummaryDto,
} from './dto/index';
import { MAX_UPLOAD_BYTES } from '../upload/upload-storage.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
@Controller('book-dock')
@RequirePermission(Permission.BookDockAccess)
@@ -58,6 +58,7 @@ export class BookDockController {
private readonly finalizeService: BookDockFinalizeService,
private readonly watcherService: BookDockWatcherService,
private readonly repo: BookDockRepository,
private readonly appSettings: AppSettingsService,
) {}
@Get('files')
@@ -109,7 +110,8 @@ export class BookDockController {
@Post('upload')
@HttpCode(HttpStatus.CREATED)
async upload(@CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
const limitMb = await this.appSettings.getMaxUploadSizeMb();
const data = await req.file({ limits: { fileSize: limitMb * 1024 * 1024 } });
if (!data) throw new BadRequestException('No file provided');
const fileId = await this.ingestService.ingestUpload(data.filename, data.file as unknown as Readable, user.id);
+1
View File
@@ -41,6 +41,7 @@ const DEFAULT_APP_SETTINGS: Array<typeof schema.appSettings.$inferInsert> = [
{ key: APP_SETTING_KEYS.OIDC_CONFIG, value: JSON.stringify(DEFAULT_OIDC_CONFIG) },
{ key: APP_SETTING_KEYS.UPDATE_CHECK_ENABLED, value: 'true' },
{ key: APP_SETTING_KEYS.CROSS_PLATFORM_PATH_SANITIZATION_ENABLED, value: 'true' },
{ key: APP_SETTING_KEYS.MAX_UPLOAD_SIZE_MB, value: '500' },
];
@Injectable()
@@ -3,7 +3,6 @@ import { Permission } from '@bookorbit/types';
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
import { BookFileUploadController } from './book-file-upload.controller';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
function makeUser(overrides: Record<string, unknown> = {}) {
return { id: 1, isSuperuser: false, permissions: [], ...overrides } as any;
@@ -14,8 +13,11 @@ describe('BookFileUploadController', () => {
addFileToBook: vi.fn(),
renameBookFiles: vi.fn(),
};
const appSettings = {
getMaxUploadSizeMb: () => Promise.resolve(500),
};
const controller = new BookFileUploadController(uploadService as any);
const controller = new BookFileUploadController(uploadService as any, appSettings as any);
beforeEach(() => {
vi.resetAllMocks();
@@ -41,7 +43,7 @@ describe('BookFileUploadController', () => {
await controller.addFileToBook(5, makeUser(), req as any);
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: MAX_UPLOAD_BYTES } });
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: 500 * 1024 * 1024 } });
});
it('delegates to uploadService.addFileToBook with correct arguments', async () => {
@@ -4,18 +4,22 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { UploadService } from './upload.service';
@Controller('books')
export class BookFileUploadController {
constructor(private readonly uploadService: UploadService) {}
constructor(
private readonly uploadService: UploadService,
private readonly appSettings: AppSettingsService,
) {}
@Post(':id/files')
@HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.LibraryUpload)
async addFileToBook(@Param('id', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
const limitMb = await this.appSettings.getMaxUploadSizeMb();
const data = await req.file({ limits: { fileSize: limitMb * 1024 * 1024 } });
if (!data) throw new BadRequestException('No file provided');
return this.uploadService.addFileToBook(bookId, data.filename, data.file, user);
}
@@ -29,7 +29,10 @@ describe('UploadStorageService', () => {
beforeEach(() => {
vi.resetAllMocks();
service = new UploadStorageService();
const mockAppSettings = {
getMaxUploadSizeMb: vi.fn().mockResolvedValue(500),
} as any;
service = new UploadStorageService(mockAppSettings);
mockMkdir.mockResolvedValue(undefined);
mockRename.mockResolvedValue(undefined);
mockCopyFile.mockResolvedValue(undefined);
@@ -8,6 +8,8 @@ import { pipeline } from 'stream/promises';
import { randomUUID } from 'crypto';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
// Hard ceiling applied at the multipart level. The service enforces a lower configurable limit.
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB
@@ -15,6 +17,8 @@ export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB
export class UploadStorageService {
private readonly logger = new Logger(UploadStorageService.name);
constructor(private readonly appSettings: AppSettingsService) {}
/**
* Streams the multipart file to a temp path on disk.
*/
@@ -31,7 +35,8 @@ export class UploadStorageService {
if ((source as Readable & { truncated?: boolean }).truncated) {
await this.cleanup(tempPath);
throw new PayloadTooLargeException(`File exceeds the ${MAX_UPLOAD_BYTES / 1024 / 1024} MB upload limit`);
const limitMb = await this.appSettings.getMaxUploadSizeMb();
throw new PayloadTooLargeException(`File exceeds the ${limitMb} MB upload limit`);
}
const { size } = await stat(tempPath);
@@ -1,14 +1,16 @@
import { BadRequestException } from '@nestjs/common';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
import { UploadController } from './upload.controller';
describe('UploadController', () => {
const uploadService = {
upload: vi.fn(),
};
const appSettings = {
getMaxUploadSizeMb: () => Promise.resolve(500),
};
const controller = new UploadController(uploadService as any);
const controller = new UploadController(uploadService as any, appSettings as any);
beforeEach(() => {
vi.resetAllMocks();
@@ -53,7 +55,7 @@ describe('UploadController', () => {
await controller.uploadBook(3, '12', { id: 5, isSuperuser: false, permissions: [] } as any, req as any);
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: MAX_UPLOAD_BYTES } });
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: 500 * 1024 * 1024 } });
expect(uploadService.upload).toHaveBeenCalledWith(3, 12, 'book.epub', stream, { id: 5, isSuperuser: false, permissions: [] });
});
@@ -5,12 +5,15 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { UploadService } from './upload.service';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
@Controller('libraries')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
constructor(
private readonly uploadService: UploadService,
private readonly appSettings: AppSettingsService,
) {}
@Post(':id/upload')
@HttpCode(HttpStatus.CREATED)
@@ -24,7 +27,8 @@ export class UploadController {
// Override the global multipart fileSize limit for book uploads.
// Per-request options are deep-merged with plugin defaults (busboy config),
// so this fileSize takes precedence over the global 20 MB cover limit.
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
const limitMb = await this.appSettings.getMaxUploadSizeMb();
const data = await req.file({ limits: { fileSize: limitMb * 1024 * 1024 } });
if (!data) throw new BadRequestException('No file provided');
const folderId = this.parseFolderId(rawFolderId);