diff --git a/.env.example b/.env.example index e7266227..ce10d499 100644 --- a/.env.example +++ b/.env.example @@ -124,6 +124,19 @@ ANTHROPIC_API_KEY= # Enterprise license (SSO, token-auth, no-tracking) LICENSE_KEY= +# ────────────────────────────────────────────── +# Optional — Upload file size limits (megabytes) +# ────────────────────────────────────────────── +# Unset = built-in defaults. Set on both api and dashboard (compose passes these through). +# Also raise any reverse-proxy body-size limit (nginx client_max_body_size, etc.) if you go high. +# UPLOAD_MAX_DOCUMENT_MB=5 # Course lesson documents (PDF, DOCX, DOC) +# UPLOAD_MAX_IMAGE_MB=5 # Avatars, media library, editor images +# UPLOAD_MAX_VIDEO_MB=800 # Lesson video uploads +# UPLOAD_MAX_EXERCISE_FILE_MB=2 # Student assignment file-upload questions +# UPLOAD_MAX_AGENT_DOCUMENT_MB=5 # AI assistant document uploads +# UPLOAD_MAX_LANDING_IMAGE_MB=0.5 # Org landing page cover images +# UPLOAD_MAX_THUMBNAIL_MB=5 # Media thumbnail uploads + # Custom branding (meta tags) # PUBLIC_APP_TITLE=My Learning Platform # PUBLIC_APP_DESCRIPTION=A flexible LMS diff --git a/README.md b/README.md index 2c7d46ab..667a8cfb 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ The repository also contains shared packages under `packages/` (for example `pac - Running the background workers? Also create `apps/jobs/.env` (e.g. `cp apps/api/.env apps/jobs/.env`); it needs `REDIS_URL`. The `packages/db/.env` used by the DB scripts is set up in step 6. - Optional for self-hosted Enterprise-only features (SSO, token-auth, no-tracking): set `LICENSE_KEY` in `apps/api/.env`. + - Optional upload size limits: set matching `UPLOAD_MAX_*_MB` values in **both** `apps/api/.env` and `apps/dashboard/.env` (see `apps/api/.env.example`). Defaults are 5 MB documents, 2 MB assignment files, 800 MB videos. 6. Start local infrastructure for API (Postgres + Redis) and seed the DB: @@ -249,7 +250,7 @@ cp .env.example .env # copy env template, edit for your domain The script reads root `.env` via `docker compose --env-file .env` and auto-generates a secure `PRIVATE_SERVER_KEY` (and `BETTER_AUTH_SECRET`) when missing. -See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. +See [`.env.example`](.env.example) for the full list of environment variables with required/optional grouping, and [`docker/docs/SELF_HOST.md`](docker/docs/SELF_HOST.md) for the complete Docker self-hosting guide. To raise upload caps (documents, videos, assignment files), set the `UPLOAD_MAX_*_MB` vars in `.env` — see the [docs](https://docs.classroomio.com/docs/self-hosted/docker#optional--upload-file-size-limits). ## Publishing diff --git a/apps/api/.env.example b/apps/api/.env.example index d7a1140f..0c555ed5 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -89,6 +89,15 @@ MOONSHOT_API_KEY="" # AGENT_MAX_FETCHES_PER_CONVERSATION=15 # AGENT_MAX_FETCHES_PER_ORG_PER_DAY=500 +# Upload file size limits (megabytes). Set these on both API and dashboard for self-hosted installs. +# UPLOAD_MAX_DOCUMENT_MB=5 +# UPLOAD_MAX_IMAGE_MB=5 +# UPLOAD_MAX_VIDEO_MB=800 +# UPLOAD_MAX_EXERCISE_FILE_MB=2 +# UPLOAD_MAX_AGENT_DOCUMENT_MB=5 +# UPLOAD_MAX_LANDING_IMAGE_MB=0.5 +# UPLOAD_MAX_THUMBNAIL_MB=5 + # Tinybird event tracking for AI observability (optional — silently skipped when not set) # TINYBIRD_TOKEN="" # TINYBIRD_BASE_URL="https://api.tinybird.co" \ No newline at end of file diff --git a/apps/api/src/constants/upload.ts b/apps/api/src/constants/upload.ts index c9e2d23a..daeea326 100644 --- a/apps/api/src/constants/upload.ts +++ b/apps/api/src/constants/upload.ts @@ -1,7 +1,15 @@ +import { getUploadLimits } from '@cio/core/config/upload-limits'; + +const limits = getUploadLimits(); + // API-specific constants (file size limits) -export const MAX_FILE_SIZE = 800 * 1024 * 1024; // 800MB -export const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB -export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB +export const MAX_FILE_SIZE = limits.videoBytes; +export const MAX_DOCUMENT_SIZE = limits.documentBytes; +export const MAX_IMAGE_SIZE = limits.imageBytes; +export const MAX_EXERCISE_FILE_SIZE = limits.exerciseFileBytes; +export const MAX_AGENT_DOCUMENT_SIZE = limits.agentDocumentBytes; + +export { getUploadLimits }; // API-specific constants (bucket names) export const BUCKET_NAME = { diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index a6511912..ff8c5f49 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -6,6 +6,7 @@ import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; import { b64EnvelopeRewrite } from '@api/middlewares/b64-envelope'; import { handleError, AppError } from '@api/utils/errors'; +import { MAX_AGENT_DOCUMENT_SIZE } from '@api/constants/upload'; import { zValidator } from '@hono/zod-validator'; import { streamText, stepCountIs, convertToModelMessages } from 'ai'; import { @@ -171,7 +172,7 @@ const agentCoreRouter = new Hono() } catch (error) { if (error instanceof AppError) { if (error.statusCode === 413) { - return c.json({ success: false, error: 'file_too_large', maxSize: 5242880 }, 413); + return c.json({ success: false, error: 'file_too_large', maxSize: MAX_AGENT_DOCUMENT_SIZE }, 413); } if (error.statusCode === 415) { diff --git a/apps/api/src/routes/course/presign.ts b/apps/api/src/routes/course/presign.ts index 7c8fcd56..8e7fd7ae 100644 --- a/apps/api/src/routes/course/presign.ts +++ b/apps/api/src/routes/course/presign.ts @@ -14,6 +14,19 @@ import { import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; import { generateFileKey } from '@cio/core/utils/upload'; +import { AppError } from '@api/utils/errors'; +import { MAX_DOCUMENT_SIZE, MAX_FILE_SIZE } from '@api/constants/upload'; + +/** + * Advisory check on client-reported `fileSize`. Upload bytes go directly to object storage + * via the presigned PUT URL, so omitting `fileSize` (or understating it) bypasses this guard. + * Real enforcement requires storage-side policies (bucket max object size, etc.). + */ +function assertPresignFileSizeWithinLimit(fileSize: number | undefined, maxBytes: number): void { + if (fileSize != null && fileSize > maxBytes) { + throw new AppError(`File size exceeds maximum of ${maxBytes / 1024 / 1024}MB`, 'FILE_TOO_LARGE', 413); + } +} // Response schemas for OpenAPI documentation const PresignUploadResponse = { @@ -68,7 +81,10 @@ export const presignRouter = new Hono() async (c) => { const body = c.req.valid('json'); - const { fileName, fileType } = body; + const { fileName, fileType, fileSize } = body; + + assertPresignFileSizeWithinLimit(fileSize, MAX_FILE_SIZE); + const fileKey = generateFileKey(fileName); const presignedUrl = await generateVideoUploadPresignedUrl(fileKey, fileType); @@ -108,7 +124,10 @@ export const presignRouter = new Hono() async (c) => { const body = c.req.valid('json'); - const { fileName, fileType } = body; + const { fileName, fileType, fileSize } = body; + + assertPresignFileSizeWithinLimit(fileSize, MAX_DOCUMENT_SIZE); + const fileKey = generateFileKey(fileName); const presignedUrl = await generateDocumentUploadPresignedUrl(fileKey, fileType); diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index 31989e75..c5ed85f0 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -52,3 +52,12 @@ PUBLIC_SENTRY_ENVIRONMENT=production PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0 PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0 PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=1 + +# Upload file size limits (megabytes). Must match apps/api/.env for self-hosted installs. +# UPLOAD_MAX_DOCUMENT_MB=5 +# UPLOAD_MAX_IMAGE_MB=5 +# UPLOAD_MAX_VIDEO_MB=800 +# UPLOAD_MAX_EXERCISE_FILE_MB=2 +# UPLOAD_MAX_AGENT_DOCUMENT_MB=5 +# UPLOAD_MAX_LANDING_IMAGE_MB=0.5 +# UPLOAD_MAX_THUMBNAIL_MB=5 diff --git a/apps/dashboard/src/lib/features/course/api/presign.svelte.ts b/apps/dashboard/src/lib/features/course/api/presign.svelte.ts index 7dd017f9..32df50ad 100644 --- a/apps/dashboard/src/lib/features/course/api/presign.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/presign.svelte.ts @@ -15,11 +15,11 @@ export class PresignApi extends BaseApiWithErrors { * Gets a presigned upload URL for a document. * @returns { url, fileKey } on success */ - async getDocumentUploadUrl(fileName: string, fileType: string) { + async getDocumentUploadUrl(fileName: string, fileType: string, fileSize?: number) { const result = await this.execute({ requestFn: () => classroomio.course.presign.document.upload.$post({ - json: { fileName, fileType } + json: { fileName, fileType, ...(fileSize != null && fileSize > 0 ? { fileSize } : {}) } }), logContext: 'getting document upload URL' }); @@ -47,11 +47,11 @@ export class PresignApi extends BaseApiWithErrors { * Gets a presigned upload URL for a video. * @returns { url, fileKey } on success */ - async getVideoUploadUrl(fileName: string, fileType: string) { + async getVideoUploadUrl(fileName: string, fileType: string, fileSize?: number) { const result = await this.execute({ requestFn: () => classroomio.course.presign.video.upload.$post({ - json: { fileName, fileType } + json: { fileName, fileType, ...(fileSize != null && fileSize > 0 ? { fileSize } : {}) } }), logContext: 'getting video upload URL' }); diff --git a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte index 4811116a..9abec214 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/edit-mode.svelte @@ -44,8 +44,11 @@ import SectionEditor from './section-editor.svelte'; import type { Question } from '$features/course/types'; import type { ExerciseEditorErrors } from './store'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; const flipDurationMs = 200; + const uploadLimits = getResolvedUploadLimits(); + const platformMaxFileSizeMb = uploadLimits.exerciseFileMb; const initialQuestionsLength = $questionnaire.questions.length; type QuestionDndEvent = CustomEvent<{ items: Question[] }>; @@ -390,6 +393,7 @@ mode: 'edit', question: toExerciseQuestionModel(question), labels: questionLabels, + platformMaxFileSizeMb, onImageUpload: uploadImage }} onQuestionChange={(nextQuestion) => onSharedQuestionChange(question.id, nextQuestion)} diff --git a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte index ef09b0e4..6dbd8c97 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/view-mode.svelte @@ -54,6 +54,7 @@ import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import type { Question } from '$features/course/types'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; interface Props { preview?: boolean; @@ -65,6 +66,9 @@ let { preview = false, exerciseId = '', isFetchingExercise = false, mySubmissions = [] }: Props = $props(); + const uploadLimits = getResolvedUploadLimits(); + const platformMaxFileSizeMb = uploadLimits.exerciseFileMb; + let isSubmitting = $state(false); /** When true, do not re-apply a past submission over a fresh take (Try again). */ let skipHydrateFromSubmissions = $state(false); @@ -305,7 +309,8 @@ file.type as | 'application/pdf' | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - | 'application/msword' + | 'application/msword', + file.size > 0 ? file.size : undefined ); if (!uploadResult) { throw new Error('Failed to get upload URL'); @@ -946,6 +951,7 @@ answer: $questionnaireMetaData.answers[sectionQuestionKey], labels: questionLabels, disabled: isSubmitting, + platformMaxFileSizeMb, onFileUpload: handleFileUpload, onVideoRecordingUpload: handleVideoRecordingUpload }} @@ -1002,6 +1008,7 @@ answer: sharedCurrentAnswer, labels: questionLabels, disabled: isSubmitting, + platformMaxFileSizeMb, onFileUpload: handleFileUpload, onVideoRecordingUpload: handleVideoRecordingUpload }} diff --git a/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte b/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte index 284bf7bb..5a6066a1 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/document/add-document-modal.svelte @@ -14,6 +14,7 @@ import * as Dialog from '@cio/ui/base/dialog'; import * as FileDropZone from '@cio/ui/custom/file-drop-zone'; import type { FileRejectedReason } from '@cio/ui/custom/file-drop-zone'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; interface Props { lessonId?: string; @@ -34,7 +35,8 @@ 'application/msword' ]; - const MAX_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB + const uploadLimits = getResolvedUploadLimits(); + const maxDocumentSize = uploadLimits.documentBytes; const documentUploader = new DocumentUploader(); @@ -58,9 +60,9 @@ return $t('course.navItem.lessons.materials.tabs.document.file_type_error'); } - if (file.size > MAX_DOCUMENT_SIZE) { + if (file.size > maxDocumentSize) { return $t('course.navItem.lessons.materials.tabs.document.file_size_error', { - size: formatFileSize(MAX_DOCUMENT_SIZE) + size: formatFileSize(maxDocumentSize) }); } @@ -87,7 +89,7 @@ if (opts.reason === 'Maximum file size exceeded') { snackbar.error( $t('course.navItem.lessons.materials.tabs.document.file_size_error', { - size: formatFileSize(MAX_DOCUMENT_SIZE) + size: formatFileSize(maxDocumentSize) }) ); } else if (opts.reason === 'File type not allowed') { @@ -259,7 +261,7 @@ accept=".pdf,.docx,.doc,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/msword" maxFiles={1} fileCount={0} - maxFileSize={MAX_DOCUMENT_SIZE} + maxFileSize={maxDocumentSize} onUpload={handleFilesUpload} onFileRejected={handleFileRejected} > diff --git a/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte index 8e31eaa6..1f5dff36 100644 --- a/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte +++ b/apps/dashboard/src/lib/features/course/components/lesson/video/upload-video.svelte @@ -14,6 +14,7 @@ import { mediaApi } from '$features/media/api'; import { JobPoller, jobsApi, type MediaJobEnvelope } from '$features/jobs'; import { onDestroy } from 'svelte'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; const ADD_VIDEO = 'course.navItem.lessons.materials.tabs.video.add_video'; const PROCESSING_BASE = `${ADD_VIDEO}.processing`; @@ -50,6 +51,9 @@ const isDisabled = $derived($lessonVideoUpload.isUploading || $isFreePlan); + const uploadLimits = getResolvedUploadLimits(); + const maxVideoSize = uploadLimits.videoBytes; + const videoUploader = new VideoUploader(); const mediaUploader = new MediaUploader(); @@ -401,7 +405,7 @@ accept="video/*" maxFiles={1} fileCount={0} - maxFileSize={800 * FileDropZone.MEGABYTE} + maxFileSize={maxVideoSize} onUpload={handleUpload} {onFileRejected} > diff --git a/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte b/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte index 21e44365..ec26afa5 100644 --- a/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte +++ b/apps/dashboard/src/lib/features/media/components/edit-asset-dialog.svelte @@ -12,6 +12,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import { t } from '$lib/utils/functions/translations'; import { uploadImage } from '$lib/utils/services/upload'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; interface Props { open?: boolean; @@ -22,7 +23,8 @@ type AssetStatusValue = 'active' | 'archived'; - const THUMBNAIL_MAX_SIZE = 5 * FileDropZone.MEGABYTE; + const uploadLimits = getResolvedUploadLimits(); + const thumbnailMaxSize = uploadLimits.thumbnailBytes; let { open = $bindable(false), asset = null, isSaving = false, onSave = async () => {} }: Props = $props(); @@ -126,7 +128,7 @@ accept={FileDropZone.ACCEPT_IMAGE} maxFiles={1} fileCount={0} - maxFileSize={THUMBNAIL_MAX_SIZE} + maxFileSize={thumbnailMaxSize} onUpload={handleThumbnailFilesUpload} onFileRejected={handleThumbnailRejected} > diff --git a/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte b/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte index c431a261..8b936d3f 100644 --- a/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte +++ b/apps/dashboard/src/lib/features/media/components/manage-thumbnails-dialog.svelte @@ -14,6 +14,7 @@ import { t } from '$lib/utils/functions/translations'; import { uploadImage } from '$lib/utils/services/upload'; import { classroomio } from '$lib/utils/services/api'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; function buildHlsUrl(asset: OrganizationAsset): string | null { if (!asset.hlsManifestKey) return null; @@ -32,7 +33,8 @@ onThumbnailSaved?: (asset: OrganizationAsset) => void; } - const THUMBNAIL_MAX_SIZE = 1 * FileDropZone.MEGABYTE; + const uploadLimits = getResolvedUploadLimits(); + const thumbnailMaxSize = uploadLimits.thumbnailBytes; const REGEN_POLL_INTERVAL_MS = 2000; const REGEN_POLL_TIMEOUT_MS = 60_000; const SLOT_KEYS = ['start', 'middle', 'end'] as const; @@ -289,7 +291,7 @@ accept={FileDropZone.ACCEPT_IMAGE} maxFiles={1} fileCount={0} - maxFileSize={THUMBNAIL_MAX_SIZE} + maxFileSize={thumbnailMaxSize} onUpload={handleCustomUpload} onFileRejected={handleUploadRejected} > diff --git a/apps/dashboard/src/lib/features/ui/upload-widget/upload-widget.svelte b/apps/dashboard/src/lib/features/ui/upload-widget/upload-widget.svelte index de2423af..eb5b113f 100644 --- a/apps/dashboard/src/lib/features/ui/upload-widget/upload-widget.svelte +++ b/apps/dashboard/src/lib/features/ui/upload-widget/upload-widget.svelte @@ -14,6 +14,7 @@ import { InputField } from '@cio/ui/custom/input-field'; import { Button } from '@cio/ui/base/button'; import UploadCloudIcon from '@lucide/svelte/icons/upload-cloud'; + import { getResolvedUploadLimits } from '$lib/utils/config/upload-limits-context'; interface Props { imageURL?: string; @@ -48,7 +49,8 @@ alt_description: string; }[] = $state([]); - const MAX_IMAGE_SIZE = 500 * 1000; + const uploadLimits = getResolvedUploadLimits(); + const maxLandingImageSize = uploadLimits.landingImageBytes; async function handleImageClick(img: string) { onchange?.(img); @@ -58,7 +60,7 @@ } function handleUnsupportedFile(file: File) { - if (file.size > MAX_IMAGE_SIZE) { + if (file.size > maxLandingImageSize) { snackbar.error('snackbar.landing_page_settings.error.file_size'); return; } @@ -130,7 +132,7 @@ bind:src={cropperSrc} onCropped={handleCropped} onUnsupportedFile={handleUnsupportedFile} - maxFileSize={MAX_IMAGE_SIZE} + maxFileSize={maxLandingImageSize} accept=".jpg, .jpeg, .png, .webp" disabled={isUploading} > diff --git a/apps/dashboard/src/lib/utils/config/upload-limits-context.ts b/apps/dashboard/src/lib/utils/config/upload-limits-context.ts new file mode 100644 index 00000000..cff25887 --- /dev/null +++ b/apps/dashboard/src/lib/utils/config/upload-limits-context.ts @@ -0,0 +1,23 @@ +import { getContext, setContext } from 'svelte'; +import { resolveUploadLimits, type UploadLimits } from '@cio/utils/config/upload-limits'; +import type { UploadLimits as DashboardUploadLimits } from './upload-limits'; + +const UPLOAD_LIMITS_CONTEXT_KEY = Symbol('upload-limits'); + +export function setUploadLimitsContext(limits: DashboardUploadLimits) { + setContext(UPLOAD_LIMITS_CONTEXT_KEY, limits); +} + +export function getUploadLimitsContext(): DashboardUploadLimits | null { + return getContext(UPLOAD_LIMITS_CONTEXT_KEY) ?? null; +} + +/** Built-in defaults when layout context is absent (e.g. Storybook, isolated tests). */ +export function getDefaultUploadLimits(): UploadLimits { + return resolveUploadLimits({}); +} + +/** Layout-provided limits, or built-in defaults outside the dashboard layout tree. */ +export function getResolvedUploadLimits(): UploadLimits { + return getUploadLimitsContext() ?? getDefaultUploadLimits(); +} diff --git a/apps/dashboard/src/lib/utils/config/upload-limits.ts b/apps/dashboard/src/lib/utils/config/upload-limits.ts new file mode 100644 index 00000000..f39f540b --- /dev/null +++ b/apps/dashboard/src/lib/utils/config/upload-limits.ts @@ -0,0 +1,24 @@ +import { resolveUploadLimits, type UploadLimits } from '@cio/utils/config/upload-limits'; +import { env } from '$env/dynamic/private'; + +let cachedLimits: UploadLimits | null = null; + +export function getUploadLimits(): UploadLimits { + if (cachedLimits) { + return cachedLimits; + } + + cachedLimits = resolveUploadLimits({ + UPLOAD_MAX_DOCUMENT_MB: env.UPLOAD_MAX_DOCUMENT_MB, + UPLOAD_MAX_IMAGE_MB: env.UPLOAD_MAX_IMAGE_MB, + UPLOAD_MAX_VIDEO_MB: env.UPLOAD_MAX_VIDEO_MB, + UPLOAD_MAX_EXERCISE_FILE_MB: env.UPLOAD_MAX_EXERCISE_FILE_MB, + UPLOAD_MAX_AGENT_DOCUMENT_MB: env.UPLOAD_MAX_AGENT_DOCUMENT_MB, + UPLOAD_MAX_LANDING_IMAGE_MB: env.UPLOAD_MAX_LANDING_IMAGE_MB, + UPLOAD_MAX_THUMBNAIL_MB: env.UPLOAD_MAX_THUMBNAIL_MB + }); + + return cachedLimits; +} + +export type { UploadLimits }; diff --git a/apps/dashboard/src/lib/utils/services/courses/presign.ts b/apps/dashboard/src/lib/utils/services/courses/presign.ts index eccd1d41..c0a61bb7 100644 --- a/apps/dashboard/src/lib/utils/services/courses/presign.ts +++ b/apps/dashboard/src/lib/utils/services/courses/presign.ts @@ -52,8 +52,12 @@ export class GenericUploader { async getPresignedUrl(file: File) { const result = this.uploadType === 'document' - ? await presignApi.getDocumentUploadUrl(file?.name ?? '', file?.type ?? '') - : await presignApi.getVideoUploadUrl(file?.name ?? '', file?.type ?? ''); + ? await presignApi.getDocumentUploadUrl( + file?.name ?? '', + file?.type ?? '', + file.size > 0 ? file.size : undefined + ) + : await presignApi.getVideoUploadUrl(file?.name ?? '', file?.type ?? '', file.size > 0 ? file.size : undefined); if (!result) { throw new Error('Failed to get presigned upload URL'); diff --git a/apps/dashboard/src/routes/+layout.server.ts b/apps/dashboard/src/routes/+layout.server.ts index 97db59a1..1d9e3882 100644 --- a/apps/dashboard/src/routes/+layout.server.ts +++ b/apps/dashboard/src/routes/+layout.server.ts @@ -1,8 +1,10 @@ import type { AccountOrg } from '$features/app/types'; import type { MetaTagsProps } from 'svelte-meta-tags'; +import type { UploadLimits } from '$lib/utils/config/upload-limits'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import { getBaseMetaTags } from '$lib/utils/functions/metaTags'; import { getOrgSiteInfo } from '$features/app/layout-setup'; +import { getUploadLimits } from '$lib/utils/config/upload-limits'; export const ssr = true; @@ -15,6 +17,7 @@ interface LoadOutput { serverLang: string; localeCookie: string; locals: App.Locals; + uploadLimits: UploadLimits; } export const load = async ({ url, cookies, request, locals }): Promise => { @@ -33,7 +36,8 @@ export const load = async ({ url, cookies, request, locals }): Promise { diff --git a/apps/docs/content/docs/self-hosted/coolify.mdx b/apps/docs/content/docs/self-hosted/coolify.mdx index eccfdd63..907bb790 100644 --- a/apps/docs/content/docs/self-hosted/coolify.mdx +++ b/apps/docs/content/docs/self-hosted/coolify.mdx @@ -46,6 +46,9 @@ import { Callout } from 'fumadocs-ui/components/callout'; storage instead? Point the same `OBJECT_STORAGE_*` variables at external **S3 / Cloudflare R2** and leave the `minio` profile off — see the [Cloudflare Setup](/docs/contributor-guides/cloudflare-setup) guide for R2. + **Upload size limits** (optional): set `UPLOAD_MAX_*_MB` in Coolify's env for both the `api` and + `dashboard` services (same values in each). See [Docker self-hosting → Upload file size limits](/docs/self-hosted/docker#optional--upload-file-size-limits). + 5. **Deploy.** Coolify pulls `classroomio/{api,dashboard,jobs}:${CIO_VERSION}`, starts Postgres + Redis, the API container runs database setup on first boot, then the dashboard and the **jobs worker** come up. ## Upgrades & rollback diff --git a/apps/docs/content/docs/self-hosted/docker.mdx b/apps/docs/content/docs/self-hosted/docker.mdx index 7a03c8b4..722585af 100644 --- a/apps/docs/content/docs/self-hosted/docker.mdx +++ b/apps/docs/content/docs/self-hosted/docker.mdx @@ -210,6 +210,25 @@ The bundled **MinIO** is included by default and the startup script auto-configu | `LICENSE_KEY` | Enterprise features (SSO, token-auth, no-tracking) | | `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` | Custom branding | +### Optional — Upload file size limits + +Set these in the root `.env` to raise or lower upload caps (values are in **megabytes**). Compose passes them through to the `api` and `dashboard` containers. Unset = built-in defaults. On [Railway](/docs/self-hosted/railway) or [Coolify](/docs/self-hosted/coolify), set the same variables on both the API and dashboard services. + +| Variable | Default | Applies to | +|----------|---------|------------| +| `UPLOAD_MAX_DOCUMENT_MB` | 5 | Course lesson documents | +| `UPLOAD_MAX_IMAGE_MB` | 5 | Avatars, media library, editor images | +| `UPLOAD_MAX_VIDEO_MB` | 800 | Lesson video uploads | +| `UPLOAD_MAX_EXERCISE_FILE_MB` | 2 | Student assignment file-upload questions | +| `UPLOAD_MAX_AGENT_DOCUMENT_MB` | 5 | AI assistant document uploads | +| `UPLOAD_MAX_LANDING_IMAGE_MB` | 0.5 | Org landing page cover images | +| `UPLOAD_MAX_THUMBNAIL_MB` | 5 | Media thumbnail uploads | + + + If you raise these values, also increase your reverse proxy body-size limit (e.g. nginx + `client_max_body_size`) so large uploads are not rejected before they reach ClassroomIO. + + ## Common Commands ```zsh title="Terminal" diff --git a/apps/docs/content/docs/self-hosted/railway.mdx b/apps/docs/content/docs/self-hosted/railway.mdx index 0db8dde8..36452101 100644 --- a/apps/docs/content/docs/self-hosted/railway.mdx +++ b/apps/docs/content/docs/self-hosted/railway.mdx @@ -62,6 +62,8 @@ ALLOWED_EXTERNAL_DOMAINS="https://fonts.googleapis.com" Optional: add `PUBLIC_APP_TITLE`, `PUBLIC_APP_DESCRIPTION`, `PUBLIC_OG_IMAGE_URL` for custom branding. You can also use per-directive CSP vars (`CSP_SCRIPT_SRC_DOMAINS`, `CSP_MEDIA_SRC_DOMAINS`, etc.) instead of `ALLOWED_EXTERNAL_DOMAINS` for finer control. +**Upload size limits** (optional): add the same `UPLOAD_MAX_*_MB` values to **cio-api** and **cio-dashboard** (e.g. `UPLOAD_MAX_DOCUMENT_MB=50`). See [Docker self-hosting → Upload file size limits](/docs/self-hosted/docker#optional--upload-file-size-limits). + ## cio-jobs (background worker) The jobs worker has **no public domain** — it drains the Redis queue (video processing, transcription, AI course generation, and emails). It shares the same Postgres, Redis, storage, email, and OpenAI settings as the API: diff --git a/docker-compose.images.yaml b/docker-compose.images.yaml index ff884f65..cdbe9c51 100644 --- a/docker-compose.images.yaml +++ b/docker-compose.images.yaml @@ -28,8 +28,8 @@ services: # (pg_isready can pass while DB is still in recovery, causing 57P03 on sign-in) test: [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'", + 'CMD-SHELL', + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'" ] interval: 5s timeout: 5s @@ -39,11 +39,11 @@ services: redis: image: redis:7-alpine container_name: cio-redis - command: ["redis-server", "--appendonly", "yes"] + command: ['redis-server', '--appendonly', 'yes'] volumes: - redis-data:/data healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 10 @@ -54,8 +54,8 @@ services: container_name: cio-minio command: server /data --console-address ":9001" ports: - - "9000:9000" - - "9001:9001" + - '9000:9000' + - '9001:9001' environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -91,7 +91,7 @@ services: image: classroomio/api:${CIO_VERSION:-latest} container_name: cio-api ports: - - "3081:3081" + - '3081:3081' depends_on: postgres: condition: service_healthy @@ -101,10 +101,10 @@ services: # node:slim has no curl/wget; use Node's global fetch (Node 20+). test: [ - "CMD", - "node", - "-e", - "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + 'CMD', + 'node', + '-e', + "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ] interval: 10s timeout: 5s @@ -138,6 +138,12 @@ services: DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} LICENSE_KEY: ${LICENSE_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} + # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. + UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} + UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} + UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} + UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} + UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set a strong BETTER_AUTH_SECRET in .env} SKIP_DB_SETUP: ${SKIP_DB_SETUP:-} restart: unless-stopped @@ -146,7 +152,7 @@ services: image: classroomio/dashboard:${CIO_VERSION:-latest} container_name: cio-dashboard ports: - - "3082:3082" + - '3082:3082' depends_on: api: condition: service_healthy @@ -166,6 +172,14 @@ services: CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-} CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-} CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-} + # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. + UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} + UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} + UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} + UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} + UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} + UPLOAD_MAX_LANDING_IMAGE_MB: ${UPLOAD_MAX_LANDING_IMAGE_MB:-} + UPLOAD_MAX_THUMBNAIL_MB: ${UPLOAD_MAX_THUMBNAIL_MB:-} restart: unless-stopped # Background worker (BullMQ). Drains the Redis queue: video processing, transcription, diff --git a/docker-compose.yaml b/docker-compose.yaml index 39fa6a94..d0888f2b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,7 +5,7 @@ services: # Bound to localhost only so host tools (pnpm api:dev, db:setup) can reach it # without exposing the database to the LAN. ports: - - "127.0.0.1:5432:5432" + - '127.0.0.1:5432:5432' environment: POSTGRES_DB: ${POSTGRES_DB:-classroomio} POSTGRES_USER: ${POSTGRES_USER:-postgres} @@ -17,8 +17,8 @@ services: # (pg_isready can pass while DB is still in recovery, causing 57P03 on sign-in) test: [ - "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'", + 'CMD-SHELL', + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} && psql -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-classroomio} -tAc 'SELECT 1'" ] interval: 5s timeout: 5s @@ -30,12 +30,12 @@ services: container_name: cio-redis # Bound to localhost only (see postgres note above). ports: - - "127.0.0.1:6379:6379" - command: ["redis-server", "--appendonly", "yes"] + - '127.0.0.1:6379:6379' + command: ['redis-server', '--appendonly', 'yes'] volumes: - redis-data:/data healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 10 @@ -46,8 +46,8 @@ services: container_name: cio-minio command: server /data --console-address ":9001" ports: - - "9000:9000" - - "9001:9001" + - '9000:9000' + - '9001:9001' environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -85,7 +85,7 @@ services: dockerfile: docker/Dockerfile.api container_name: cio-api ports: - - "3081:3081" + - '3081:3081' depends_on: postgres: condition: service_healthy @@ -95,10 +95,10 @@ services: # node:slim has no curl/wget; use Node's global fetch (Node 20+). test: [ - "CMD", - "node", - "-e", - "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + 'CMD', + 'node', + '-e', + "fetch('http://localhost:3081/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ] interval: 10s timeout: 5s @@ -132,6 +132,12 @@ services: DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} LICENSE_KEY: ${LICENSE_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} + # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. + UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} + UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} + UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} + UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} + UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set a strong BETTER_AUTH_SECRET in .env} SKIP_DB_SETUP: ${SKIP_DB_SETUP:-} restart: unless-stopped @@ -144,7 +150,7 @@ services: PUBLIC_IS_SELFHOSTED: ${PUBLIC_IS_SELFHOSTED:-true} container_name: cio-dashboard ports: - - "3082:3082" + - '3082:3082' depends_on: api: condition: service_healthy @@ -164,6 +170,14 @@ services: CSP_FRAME_SRC_DOMAINS: ${CSP_FRAME_SRC_DOMAINS:-} CSP_FONT_SRC_DOMAINS: ${CSP_FONT_SRC_DOMAINS:-} CSP_MEDIA_SRC_DOMAINS: ${CSP_MEDIA_SRC_DOMAINS:-} + # Upload size limits (megabytes). Unset = built-in defaults — see .env.example. + UPLOAD_MAX_DOCUMENT_MB: ${UPLOAD_MAX_DOCUMENT_MB:-} + UPLOAD_MAX_IMAGE_MB: ${UPLOAD_MAX_IMAGE_MB:-} + UPLOAD_MAX_VIDEO_MB: ${UPLOAD_MAX_VIDEO_MB:-} + UPLOAD_MAX_EXERCISE_FILE_MB: ${UPLOAD_MAX_EXERCISE_FILE_MB:-} + UPLOAD_MAX_AGENT_DOCUMENT_MB: ${UPLOAD_MAX_AGENT_DOCUMENT_MB:-} + UPLOAD_MAX_LANDING_IMAGE_MB: ${UPLOAD_MAX_LANDING_IMAGE_MB:-} + UPLOAD_MAX_THUMBNAIL_MB: ${UPLOAD_MAX_THUMBNAIL_MB:-} restart: unless-stopped # Background worker (BullMQ). Drains the Redis queue: video processing, transcription, diff --git a/docker/docs/SELF_HOST.md b/docker/docs/SELF_HOST.md index 6bec0455..4928e127 100644 --- a/docker/docs/SELF_HOST.md +++ b/docker/docs/SELF_HOST.md @@ -88,6 +88,7 @@ Key points: - **Auto-configured:** All `MINIO_*` / `OBJECT_STORAGE_*` vars (by the startup script, with randomized MinIO credentials). - **Email (effectively required):** SMTP (or Zoho). See [Email](#email) — without it, signup verification, password reset, and invites do not send. - **Optional:** Google OAuth, Unsplash, `LICENSE_KEY` (enterprise). +- **Optional — upload limits:** `UPLOAD_MAX_*_MB` vars (documents, images, videos, assignment files, etc.). See [`.env.example`](../../.env.example). Passed through to both `api` and `dashboard` containers; unset values use built-in defaults. Raise your reverse-proxy body-size limit too if you increase these. `PRIVATE_SERVER_KEY` must be the same value in both API and dashboard — the script ensures this. diff --git a/packages/core/src/config/env.ts b/packages/core/src/config/env.ts index 61bfa7b8..30ae0281 100644 --- a/packages/core/src/config/env.ts +++ b/packages/core/src/config/env.ts @@ -88,7 +88,21 @@ const envSchema = z.object({ const parsed = Number(v); return Number.isFinite(parsed) && parsed >= 1 ? Math.trunc(parsed) : 500; - }) + }), + /** Max course lesson document upload size in megabytes (default 5). */ + UPLOAD_MAX_DOCUMENT_MB: z.string().optional(), + /** Max image upload size in megabytes — avatars, media, editor images (default 5). */ + UPLOAD_MAX_IMAGE_MB: z.string().optional(), + /** Max lesson video upload size in megabytes (default 800). */ + UPLOAD_MAX_VIDEO_MB: z.string().optional(), + /** Max exercise assignment file upload size in megabytes (default 2). */ + UPLOAD_MAX_EXERCISE_FILE_MB: z.string().optional(), + /** Max AI assistant document upload size in megabytes (default 5). */ + UPLOAD_MAX_AGENT_DOCUMENT_MB: z.string().optional(), + /** Max org landing page image upload size in megabytes (default 0.5). */ + UPLOAD_MAX_LANDING_IMAGE_MB: z.string().optional(), + /** Max media thumbnail upload size in megabytes (default 5). */ + UPLOAD_MAX_THUMBNAIL_MB: z.string().optional() }); export const env = envSchema.parse(process.env); diff --git a/packages/core/src/config/upload-limits.ts b/packages/core/src/config/upload-limits.ts new file mode 100644 index 00000000..62ada419 --- /dev/null +++ b/packages/core/src/config/upload-limits.ts @@ -0,0 +1,15 @@ +import { env } from './env'; +import { resolveUploadLimits, type UploadLimits } from '@cio/utils/config/upload-limits'; + +export type { UploadLimits } from '@cio/utils/config/upload-limits'; + +let cachedLimits: UploadLimits | null = null; + +export function getUploadLimits(): UploadLimits { + if (cachedLimits) { + return cachedLimits; + } + + cachedLimits = resolveUploadLimits(env); + return cachedLimits; +} diff --git a/packages/core/src/services/agent/document.ts b/packages/core/src/services/agent/document.ts index ca66c029..7dd0066d 100644 --- a/packages/core/src/services/agent/document.ts +++ b/packages/core/src/services/agent/document.ts @@ -1,12 +1,8 @@ import { nanoid } from 'nanoid'; import { AppError } from '@cio/utils/errors'; -import { - MAX_DOCUMENT_TEXT_LENGTH, - MAX_AGENT_DOCUMENT_SIZE, - DOCUMENT_REDIS_TTL, - SUPPORTED_DOCUMENT_TYPES -} from '@cio/ai-assistant'; +import { MAX_DOCUMENT_TEXT_LENGTH, DOCUMENT_REDIS_TTL, SUPPORTED_DOCUMENT_TYPES } from '@cio/ai-assistant'; import type { DocumentUploadResult } from '@cio/ai-assistant'; +import { getUploadLimits } from '../../config/upload-limits'; import { agentDocumentKey } from '../../utils/redis-keys'; import { trackAgentEvent, AgentEvent } from '../../utils/tinybird'; import type { RedisClient } from '../../utils/redis/redis'; @@ -34,9 +30,11 @@ export async function parseAndStoreDocument( throw new AppError('Unsupported file type. Allowed: PDF, DOCX, PPTX', 'UNSUPPORTED_FILE_TYPE', 415); } - if (file.size > MAX_AGENT_DOCUMENT_SIZE) { + const maxAgentDocumentSize = getUploadLimits().agentDocumentBytes; + + if (file.size > maxAgentDocumentSize) { throw new AppError( - `File too large. Maximum size is ${MAX_AGENT_DOCUMENT_SIZE / (1024 * 1024)}MB`, + `File too large. Maximum size is ${maxAgentDocumentSize / (1024 * 1024)}MB`, 'FILE_TOO_LARGE', 413 ); diff --git a/packages/question-types/src/exercise-types.ts b/packages/question-types/src/exercise-types.ts index ce00405e..b368d3ab 100644 --- a/packages/question-types/src/exercise-types.ts +++ b/packages/question-types/src/exercise-types.ts @@ -257,6 +257,8 @@ export interface ExerciseQuestionRendererProps { maxSubmissionItems?: number; disabled?: boolean; labels?: ExerciseQuestionLabels; + /** Platform-wide cap for exercise file uploads (megabytes). */ + platformMaxFileSizeMb?: number; onAnswerChange?: (answer: AnswerData | null) => void; onQuestionChange?: (question: ExerciseQuestionModel) => void; onImageUpload?: ExerciseQuestionImageUploader; diff --git a/packages/question-types/src/render-contract.ts b/packages/question-types/src/render-contract.ts index e947ff3a..d8142f5d 100644 --- a/packages/question-types/src/render-contract.ts +++ b/packages/question-types/src/render-contract.ts @@ -18,6 +18,8 @@ export interface ExerciseQuestionRenderContract { maxSubmissionItems?: number; disabled?: boolean; labels?: ExerciseQuestionLabels; + /** Platform-wide cap for exercise file uploads (megabytes). */ + platformMaxFileSizeMb?: number; onImageUpload?: ExerciseQuestionImageUploader; onFileUpload?: ExerciseQuestionFileUploader; onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader; @@ -31,6 +33,8 @@ export interface ExerciseQuestionListRenderContract { maxSubmissionItems?: number; disabled?: boolean; labels?: ExerciseQuestionLabels; + /** Platform-wide cap for exercise file uploads (megabytes). */ + platformMaxFileSizeMb?: number; onImageUpload?: ExerciseQuestionImageUploader; onFileUpload?: ExerciseQuestionFileUploader; onVideoRecordingUpload?: ExerciseQuestionVideoRecordingUploader; diff --git a/packages/ui/src/custom/exercise-question/question-list.svelte b/packages/ui/src/custom/exercise-question/question-list.svelte index d86b44f0..6a5fb0d1 100644 --- a/packages/ui/src/custom/exercise-question/question-list.svelte +++ b/packages/ui/src/custom/exercise-question/question-list.svelte @@ -32,6 +32,7 @@ maxSubmissionItems: contract.maxSubmissionItems, disabled: contract.disabled, labels: contract.labels, + platformMaxFileSizeMb: contract.platformMaxFileSizeMb, onImageUpload: contract.onImageUpload, onFileUpload: contract.onFileUpload, onVideoRecordingUpload: contract.onVideoRecordingUpload diff --git a/packages/ui/src/custom/exercise-question/question-renderer.svelte b/packages/ui/src/custom/exercise-question/question-renderer.svelte index d722a589..9fc04ff0 100644 --- a/packages/ui/src/custom/exercise-question/question-renderer.svelte +++ b/packages/ui/src/custom/exercise-question/question-renderer.svelte @@ -475,6 +475,7 @@ maxSubmissionItems={contract.maxSubmissionItems} disabled={contract.disabled} labels={contract.labels} + platformMaxFileSizeMb={contract.platformMaxFileSizeMb} onImageUpload={contract.onImageUpload} onFileUpload={contract.onFileUpload} onVideoRecordingUpload={contract.onVideoRecordingUpload} diff --git a/packages/ui/src/custom/exercise-question/renderers/file-upload-types.ts b/packages/ui/src/custom/exercise-question/renderers/file-upload-types.ts index 17d68926..2fbbd413 100644 --- a/packages/ui/src/custom/exercise-question/renderers/file-upload-types.ts +++ b/packages/ui/src/custom/exercise-question/renderers/file-upload-types.ts @@ -1,4 +1,6 @@ -export const FILE_UPLOAD_MAX_SIZE_MB = 2; +export const FILE_UPLOAD_DEFAULT_MAX_SIZE_MB = 2; +/** @deprecated Use FILE_UPLOAD_DEFAULT_MAX_SIZE_MB */ +export const FILE_UPLOAD_MAX_SIZE_MB = FILE_UPLOAD_DEFAULT_MAX_SIZE_MB; export const FILE_UPLOAD_SUPPORTED_TYPES = [ { value: 'application/pdf', label: 'PDF (.pdf)', aliases: ['pdf', '.pdf'] }, @@ -106,10 +108,25 @@ export function isFileTypeAllowed(file: File, acceptedTypes: string[]): boolean const BYTES_PER_MB = 1024 * 1024; -export function isFileSizeAllowed(file: File, maxSizeMb: number | undefined | null): boolean { - const effectiveMaxMb = Math.min( - typeof maxSizeMb === 'number' && maxSizeMb > 0 ? maxSizeMb : FILE_UPLOAD_MAX_SIZE_MB, - FILE_UPLOAD_MAX_SIZE_MB - ); +export function resolveExerciseFileUploadMaxSizeMb( + questionMaxSizeMb: number | undefined | null, + platformMaxSizeMb: number = FILE_UPLOAD_DEFAULT_MAX_SIZE_MB +): number { + const effectivePlatformMax = + typeof platformMaxSizeMb === 'number' && platformMaxSizeMb > 0 + ? platformMaxSizeMb + : FILE_UPLOAD_DEFAULT_MAX_SIZE_MB; + const requestedMax = + typeof questionMaxSizeMb === 'number' && questionMaxSizeMb > 0 ? questionMaxSizeMb : effectivePlatformMax; + + return Math.min(requestedMax, effectivePlatformMax); +} + +export function isFileSizeAllowed( + file: File, + maxSizeMb: number | undefined | null, + platformMaxSizeMb: number = FILE_UPLOAD_DEFAULT_MAX_SIZE_MB +): boolean { + const effectiveMaxMb = resolveExerciseFileUploadMaxSizeMb(maxSizeMb, platformMaxSizeMb); return file.size <= effectiveMaxMb * BYTES_PER_MB; } diff --git a/packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte b/packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte index eeb0e22d..f93bca8a 100644 --- a/packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte +++ b/packages/ui/src/custom/exercise-question/renderers/file-upload/edit.svelte @@ -12,10 +12,17 @@ import { FILE_UPLOAD_SUPPORTED_TYPES, formatAcceptedFileTypes, - normalizeAcceptedFileTypes + normalizeAcceptedFileTypes, + resolveExerciseFileUploadMaxSizeMb } from '../file-upload-types'; - let { question, disabled = false, labels, onQuestionChange = () => {} }: ExerciseQuestionRendererProps = $props(); + let { + question, + disabled = false, + labels, + platformMaxFileSizeMb, + onQuestionChange = () => {} + }: ExerciseQuestionRendererProps = $props(); const label = (key: Parameters[1], fallback = '') => getExerciseQuestionLabel(labels, key, fallback); const acceptedTypes = $derived(normalizeAcceptedFileTypes(question.settings?.acceptedTypes)); @@ -41,17 +48,19 @@ patchSettings({ acceptedTypes: nextAcceptedTypes }); } + const platformMaxMb = $derived(resolveExerciseFileUploadMaxSizeMb(undefined, platformMaxFileSizeMb)); + const rawMaxSize = $derived(question.settings?.maxSizeMb as number | undefined); const maxSizeMb = $derived( typeof rawMaxSize === 'number' && !Number.isNaN(rawMaxSize) && rawMaxSize >= 0 - ? String(Math.min(rawMaxSize, 2)) + ? String(Math.min(rawMaxSize, platformMaxMb)) : '' ); $effect(() => { const raw = question.settings?.maxSizeMb as number | undefined; - if (typeof raw === 'number' && raw > 2) { - patchSettings({ maxSizeMb: 2 }); + if (typeof raw === 'number' && raw > platformMaxMb) { + patchSettings({ maxSizeMb: platformMaxMb }); } }); @@ -61,7 +70,7 @@ patchSettings({ maxSizeMb: undefined }); return; } - patchSettings({ maxSizeMb: Math.min(2, Math.max(0, num)) }); + patchSettings({ maxSizeMb: Math.min(platformMaxMb, Math.max(0, num)) }); } @@ -107,7 +116,7 @@ {}, onFileUpload }: ExerciseQuestionRendererProps = $props(); @@ -86,7 +87,7 @@ if (!selectedFile) return; if (!isFileTypeAllowed(selectedFile, acceptedTypes)) return; - if (!isFileSizeAllowed(selectedFile, maxSizeMb)) return; + if (!isFileSizeAllowed(selectedFile, maxSizeMb, platformMaxFileSizeMb)) return; if (!onFileUpload) return; diff --git a/packages/utils/package.json b/packages/utils/package.json index 55a374f8..34eb53bc 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -52,6 +52,10 @@ "./errors": { "types": "./dist/errors/index.d.ts", "default": "./dist/errors/index.js" + }, + "./config/upload-limits": { + "types": "./dist/config/upload-limits.d.ts", + "default": "./dist/config/upload-limits.js" } }, "scripts": { diff --git a/packages/utils/src/config/upload-limits.ts b/packages/utils/src/config/upload-limits.ts new file mode 100644 index 00000000..73ffd7bc --- /dev/null +++ b/packages/utils/src/config/upload-limits.ts @@ -0,0 +1,69 @@ +const BYTES_PER_MB = 1024 * 1024; + +function parseUploadLimitMb(value: string | undefined, defaultMb: number): number { + if (value === undefined || value === '') { + return defaultMb; + } + + const parsed = Number(value); + + if (!Number.isFinite(parsed) || parsed <= 0) { + return defaultMb; + } + + return parsed; +} + +export type UploadLimits = { + documentMb: number; + imageMb: number; + videoMb: number; + exerciseFileMb: number; + agentDocumentMb: number; + landingImageMb: number; + thumbnailMb: number; + documentBytes: number; + imageBytes: number; + videoBytes: number; + exerciseFileBytes: number; + agentDocumentBytes: number; + landingImageBytes: number; + thumbnailBytes: number; +}; + +export type UploadLimitEnv = { + UPLOAD_MAX_DOCUMENT_MB?: string; + UPLOAD_MAX_IMAGE_MB?: string; + UPLOAD_MAX_VIDEO_MB?: string; + UPLOAD_MAX_EXERCISE_FILE_MB?: string; + UPLOAD_MAX_AGENT_DOCUMENT_MB?: string; + UPLOAD_MAX_LANDING_IMAGE_MB?: string; + UPLOAD_MAX_THUMBNAIL_MB?: string; +}; + +export function resolveUploadLimits(env: UploadLimitEnv): UploadLimits { + const documentMb = parseUploadLimitMb(env.UPLOAD_MAX_DOCUMENT_MB, 5); + const imageMb = parseUploadLimitMb(env.UPLOAD_MAX_IMAGE_MB, 5); + const videoMb = parseUploadLimitMb(env.UPLOAD_MAX_VIDEO_MB, 800); + const exerciseFileMb = parseUploadLimitMb(env.UPLOAD_MAX_EXERCISE_FILE_MB, 2); + const agentDocumentMb = parseUploadLimitMb(env.UPLOAD_MAX_AGENT_DOCUMENT_MB, 5); + const landingImageMb = parseUploadLimitMb(env.UPLOAD_MAX_LANDING_IMAGE_MB, 0.5); + const thumbnailMb = parseUploadLimitMb(env.UPLOAD_MAX_THUMBNAIL_MB, 5); + + return { + documentMb, + imageMb, + videoMb, + exerciseFileMb, + agentDocumentMb, + landingImageMb, + thumbnailMb, + documentBytes: documentMb * BYTES_PER_MB, + imageBytes: imageMb * BYTES_PER_MB, + videoBytes: videoMb * BYTES_PER_MB, + exerciseFileBytes: exerciseFileMb * BYTES_PER_MB, + agentDocumentBytes: agentDocumentMb * BYTES_PER_MB, + landingImageBytes: landingImageMb * BYTES_PER_MB, + thumbnailBytes: thumbnailMb * BYTES_PER_MB + }; +} diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index 60554c2a..110e5506 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -127,13 +127,15 @@ export type TLessonDownloadContent = z.infer; export const ZCoursePresignUrlUpload = z.object({ fileName: z.string().min(1), - fileType: z.enum(ALLOWED_CONTENT_TYPES) + fileType: z.enum(ALLOWED_CONTENT_TYPES), + fileSize: z.number().int().min(0).optional() }); export type TCoursePresignUrlUpload = z.infer; export const ZCourseDocumentPresignUrlUpload = z.object({ fileName: z.string().min(1), - fileType: z.enum(ALLOWED_DOCUMENT_TYPES) + fileType: z.enum(ALLOWED_DOCUMENT_TYPES), + fileSize: z.number().int().min(0).optional() }); export type TCourseDocumentPresignUrlUpload = z.infer;