From ea78e813214d9e9c56c81260738f7d399b9977c0 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 27 Apr 2026 13:35:02 +0300 Subject: [PATCH] feat: add configurable file upload limit via environment variable (#1080) * fix: replace 6-word limit with 150-character limit for project names Replaces the word-based validation with character-based validation as originally requested in #971. The 6-word limit was causing issues with small words and separators being counted equally, and didn't match the original requirement for a character limit. Changes: - Backend: Replace wordCount validator with len validator (1-150 chars) - Frontend: Replace word count validation with character length check - UI already has line-clamp-3 for display truncation Fixes #998 * fix: make password_digest migration compatible with all schema versions Fixes a critical bug where the make-password-optional migration would silently fail when upgrading from v1.0.0 or running on fresh v1.1.0-dev installations. The migration was trying to SELECT columns (ai_provider, openai_api_key, ollama_base_url, ollama_model) that don't exist in the users table at that point in the migration chain, causing the INSERT...SELECT to fail and leaving password_digest as NOT NULL. This prevented OIDC auto-provisioning from creating new users without passwords. The fix dynamically detects which columns exist in the users table using PRAGMA table_info and only selects columns that are guaranteed to exist. Missing columns (AI-related fields) will receive their default values from the new table schema. Changes: - Added dynamic column detection using PRAGMA table_info - Only SELECT columns that exist in the current users table - AI columns get default values if they don't exist yet - Applied same fix to both up and down migrations - Properly handle password/password_digest column name migration Fixes #1075 * feat: add configurable file upload limit via environment variable Add FILE_UPLOAD_LIMIT_MB environment variable to make file upload limits configurable. Previously hardcoded at 10MB, users can now customize this via Docker environment variables or .env configuration to support larger file attachments. Changes: - Add FILE_UPLOAD_LIMIT_MB config with 10MB default fallback - Update multer limits in tasks/attachments and projects routes - Update Express body parser limits to use dynamic config - Add /api/config endpoint to expose file limit to frontend - Update frontend validation to fetch and use server config - Add configService.ts for caching server configuration - Update documentation with new environment variable Fixes #1000 --- backend/app.js | 7 +++-- backend/config/config.js | 5 ++++ backend/modules/auth/controller.js | 8 +++++ backend/modules/auth/routes.js | 1 + backend/modules/projects/routes.js | 2 +- backend/modules/tasks/attachments.js | 2 +- docs/00-tasks-behavior.md | 2 +- docs/development-workflow.md | 1 + .../Task/TaskDetails/TaskAttachmentsCard.tsx | 2 +- .../Task/TaskForm/TaskAttachmentsSection.tsx | 2 +- frontend/utils/attachmentsService.ts | 11 ++++--- frontend/utils/configService.ts | 29 +++++++++++++++++++ 12 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 frontend/utils/configService.ts diff --git a/backend/app.js b/backend/app.js index 291d596..bb2d984 100644 --- a/backend/app.js +++ b/backend/app.js @@ -104,7 +104,7 @@ app.use((req, res, next) => { return next(); } - express.json({ limit: '10mb' })(req, res, next); + express.json({ limit: `${config.fileUploadLimitMB}mb` })(req, res, next); }); app.use((req, res, next) => { @@ -116,7 +116,10 @@ app.use((req, res, next) => { return next(); } - express.urlencoded({ extended: true, limit: '10mb' })(req, res, next); + express.urlencoded({ + extended: true, + limit: `${config.fileUploadLimitMB}mb`, + })(req, res, next); }); // CalDAV routes (registered after conditional body parsers) diff --git a/backend/config/config.js b/backend/config/config.js index 54e5695..5f662f1 100644 --- a/backend/config/config.js +++ b/backend/config/config.js @@ -103,6 +103,11 @@ const config = { uploadPath: process.env.TUDUDI_UPLOAD_PATH || path.join(projectRootPath, 'uploads'), + // File upload limit in MB (default 10MB) + fileUploadLimitMB: process.env.FILE_UPLOAD_LIMIT_MB + ? parseInt(process.env.FILE_UPLOAD_LIMIT_MB, 10) + : 10, + // API Documentation (Swagger) swagger: { enabled: process.env.SWAGGER_ENABLED !== 'false', diff --git a/backend/modules/auth/controller.js b/backend/modules/auth/controller.js index 5644f91..7ac0093 100644 --- a/backend/modules/auth/controller.js +++ b/backend/modules/auth/controller.js @@ -4,12 +4,20 @@ const authService = require('./service'); const { logError } = require('../../services/logService'); const { generateToken } = require('../../middleware/csrf'); const { isPasswordAuthEnabled } = require('../../config/authConfig'); +const { getConfig } = require('../../config/config'); const authController = { getVersion(req, res) { res.json(authService.getVersion()); }, + getPublicConfig(req, res) { + const config = getConfig(); + res.json({ + fileUploadLimitMB: config.fileUploadLimitMB, + }); + }, + async getRegistrationStatus(req, res, next) { try { const result = await authService.getRegistrationStatus(); diff --git a/backend/modules/auth/routes.js b/backend/modules/auth/routes.js index 11c86c1..672da41 100644 --- a/backend/modules/auth/routes.js +++ b/backend/modules/auth/routes.js @@ -7,6 +7,7 @@ const { authLimiter, apiLimiter } = require('../../middleware/rateLimiter'); const { csrfMiddleware } = require('../../middleware/csrf'); router.get('/version', authController.getVersion); +router.get('/config', authController.getPublicConfig); router.get('/registration-status', authController.getRegistrationStatus); router.get( '/password-auth-status', diff --git a/backend/modules/projects/routes.js b/backend/modules/projects/routes.js index 792ca8a..ffb8f26 100644 --- a/backend/modules/projects/routes.js +++ b/backend/modules/projects/routes.js @@ -29,7 +29,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, limits: { - fileSize: 10 * 1024 * 1024, // 10MB limit + fileSize: config.fileUploadLimitMB * 1024 * 1024, }, fileFilter: function (req, file, cb) { const allowedTypes = /jpeg|jpg|png|gif|webp/; diff --git a/backend/modules/tasks/attachments.js b/backend/modules/tasks/attachments.js index ddedcd1..46dce50 100644 --- a/backend/modules/tasks/attachments.js +++ b/backend/modules/tasks/attachments.js @@ -49,7 +49,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, limits: { - fileSize: 10 * 1024 * 1024, // 10MB limit + fileSize: config.fileUploadLimitMB * 1024 * 1024, }, fileFilter: function (req, file, cb) { if (validateFileType(file.mimetype)) { diff --git a/docs/00-tasks-behavior.md b/docs/00-tasks-behavior.md index e766e13..289b521 100644 --- a/docs/00-tasks-behavior.md +++ b/docs/00-tasks-behavior.md @@ -157,7 +157,7 @@ This document explains how tasks work in tududi from a user behavior perspective 22. **Tasks can have file attachments:** - Multiple files can be attached to a single task - - File size limit: 10MB per file + - File size limit: 10MB per file (configurable via `FILE_UPLOAD_LIMIT_MB` environment variable) - Stored in `/uploads/tasks/` directory 23. **Allowed file types:** diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 0ae4f4c..1e5850f 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -115,6 +115,7 @@ FRONTEND_URL=http://localhost:8080 BACKEND_URL=http://localhost:3002 PORT=3002 HOST=0.0.0.0 +FILE_UPLOAD_LIMIT_MB=10 # Optional - Email ENABLE_EMAIL=false diff --git a/frontend/components/Task/TaskDetails/TaskAttachmentsCard.tsx b/frontend/components/Task/TaskDetails/TaskAttachmentsCard.tsx index 3dbc1c7..b15d44f 100644 --- a/frontend/components/Task/TaskDetails/TaskAttachmentsCard.tsx +++ b/frontend/components/Task/TaskDetails/TaskAttachmentsCard.tsx @@ -73,7 +73,7 @@ const TaskAttachmentsCard: React.FC = ({ if (!file) return; // Validate file - const validation = validateFile(file); + const validation = await validateFile(file); if (!validation.valid) { showErrorToast(validation.error || 'Invalid file'); if (fileInputRef.current) { diff --git a/frontend/components/Task/TaskForm/TaskAttachmentsSection.tsx b/frontend/components/Task/TaskForm/TaskAttachmentsSection.tsx index 220a2a2..76d5e25 100644 --- a/frontend/components/Task/TaskForm/TaskAttachmentsSection.tsx +++ b/frontend/components/Task/TaskForm/TaskAttachmentsSection.tsx @@ -37,7 +37,7 @@ const TaskAttachmentsSection: React.FC = ({ if (!file) return; // Validate file - const validation = validateFile(file); + const validation = await validateFile(file); if (!validation.valid) { showErrorToast(validation.error || 'Invalid file'); if (fileInputRef.current) { diff --git a/frontend/utils/attachmentsService.ts b/frontend/utils/attachmentsService.ts index 76c9fb6..0e5f134 100644 --- a/frontend/utils/attachmentsService.ts +++ b/frontend/utils/attachmentsService.ts @@ -1,6 +1,7 @@ import { Attachment, AttachmentType } from '../entities/Attachment'; import { getApiPath } from '../config/paths'; import { getCsrfToken } from './csrfService'; +import { getServerConfig } from './configService'; /** * Upload a file attachment to a task @@ -146,13 +147,15 @@ export function formatFileSize(bytes: number): string { /** * Validate file before upload */ -export function validateFile(file: File): { valid: boolean; error?: string } { - // Check file size (10MB max) - const maxSize = 10 * 1024 * 1024; +export async function validateFile( + file: File +): Promise<{ valid: boolean; error?: string }> { + const config = await getServerConfig(); + const maxSize = config.fileUploadLimitMB * 1024 * 1024; if (file.size > maxSize) { return { valid: false, - error: 'File size exceeds 10MB limit', + error: `File size exceeds ${config.fileUploadLimitMB}MB limit`, }; } diff --git a/frontend/utils/configService.ts b/frontend/utils/configService.ts new file mode 100644 index 0000000..d034db0 --- /dev/null +++ b/frontend/utils/configService.ts @@ -0,0 +1,29 @@ +import { getApiPath } from '../config/paths'; + +interface ServerConfig { + fileUploadLimitMB: number; +} + +let cachedConfig: ServerConfig | null = null; + +export async function getServerConfig(): Promise { + if (cachedConfig) { + return cachedConfig; + } + + const response = await fetch(getApiPath('config'), { + method: 'GET', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('Failed to fetch server configuration'); + } + + cachedConfig = await response.json(); + return cachedConfig; +} + +export function getFileUploadLimitMB(): number { + return cachedConfig?.fileUploadLimitMB || 10; +}