mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user