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
This commit is contained in:
Chris 2026-04-27 13:35:02 +03:00 committed by GitHub
parent 7948f6552c
commit ea78e81321
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 61 additions and 11 deletions

View file

@ -104,7 +104,7 @@ app.use((req, res, next) => {
return next(); return next();
} }
express.json({ limit: '10mb' })(req, res, next); express.json({ limit: `${config.fileUploadLimitMB}mb` })(req, res, next);
}); });
app.use((req, res, next) => { app.use((req, res, next) => {
@ -116,7 +116,10 @@ app.use((req, res, next) => {
return 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) // CalDAV routes (registered after conditional body parsers)

View file

@ -103,6 +103,11 @@ const config = {
uploadPath: uploadPath:
process.env.TUDUDI_UPLOAD_PATH || path.join(projectRootPath, 'uploads'), 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) // API Documentation (Swagger)
swagger: { swagger: {
enabled: process.env.SWAGGER_ENABLED !== 'false', enabled: process.env.SWAGGER_ENABLED !== 'false',

View file

@ -4,12 +4,20 @@ const authService = require('./service');
const { logError } = require('../../services/logService'); const { logError } = require('../../services/logService');
const { generateToken } = require('../../middleware/csrf'); const { generateToken } = require('../../middleware/csrf');
const { isPasswordAuthEnabled } = require('../../config/authConfig'); const { isPasswordAuthEnabled } = require('../../config/authConfig');
const { getConfig } = require('../../config/config');
const authController = { const authController = {
getVersion(req, res) { getVersion(req, res) {
res.json(authService.getVersion()); res.json(authService.getVersion());
}, },
getPublicConfig(req, res) {
const config = getConfig();
res.json({
fileUploadLimitMB: config.fileUploadLimitMB,
});
},
async getRegistrationStatus(req, res, next) { async getRegistrationStatus(req, res, next) {
try { try {
const result = await authService.getRegistrationStatus(); const result = await authService.getRegistrationStatus();

View file

@ -7,6 +7,7 @@ const { authLimiter, apiLimiter } = require('../../middleware/rateLimiter');
const { csrfMiddleware } = require('../../middleware/csrf'); const { csrfMiddleware } = require('../../middleware/csrf');
router.get('/version', authController.getVersion); router.get('/version', authController.getVersion);
router.get('/config', authController.getPublicConfig);
router.get('/registration-status', authController.getRegistrationStatus); router.get('/registration-status', authController.getRegistrationStatus);
router.get( router.get(
'/password-auth-status', '/password-auth-status',

View file

@ -29,7 +29,7 @@ const storage = multer.diskStorage({
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit fileSize: config.fileUploadLimitMB * 1024 * 1024,
}, },
fileFilter: function (req, file, cb) { fileFilter: function (req, file, cb) {
const allowedTypes = /jpeg|jpg|png|gif|webp/; const allowedTypes = /jpeg|jpg|png|gif|webp/;

View file

@ -49,7 +49,7 @@ const storage = multer.diskStorage({
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit fileSize: config.fileUploadLimitMB * 1024 * 1024,
}, },
fileFilter: function (req, file, cb) { fileFilter: function (req, file, cb) {
if (validateFileType(file.mimetype)) { if (validateFileType(file.mimetype)) {

View file

@ -157,7 +157,7 @@ This document explains how tasks work in tududi from a user behavior perspective
22. **Tasks can have file attachments:** 22. **Tasks can have file attachments:**
- Multiple files can be attached to a single task - 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 - Stored in `/uploads/tasks/` directory
23. **Allowed file types:** 23. **Allowed file types:**

View file

@ -115,6 +115,7 @@ FRONTEND_URL=http://localhost:8080
BACKEND_URL=http://localhost:3002 BACKEND_URL=http://localhost:3002
PORT=3002 PORT=3002
HOST=0.0.0.0 HOST=0.0.0.0
FILE_UPLOAD_LIMIT_MB=10
# Optional - Email # Optional - Email
ENABLE_EMAIL=false ENABLE_EMAIL=false

View file

@ -73,7 +73,7 @@ const TaskAttachmentsCard: React.FC<TaskAttachmentsCardProps> = ({
if (!file) return; if (!file) return;
// Validate file // Validate file
const validation = validateFile(file); const validation = await validateFile(file);
if (!validation.valid) { if (!validation.valid) {
showErrorToast(validation.error || 'Invalid file'); showErrorToast(validation.error || 'Invalid file');
if (fileInputRef.current) { if (fileInputRef.current) {

View file

@ -37,7 +37,7 @@ const TaskAttachmentsSection: React.FC<TaskAttachmentsSectionProps> = ({
if (!file) return; if (!file) return;
// Validate file // Validate file
const validation = validateFile(file); const validation = await validateFile(file);
if (!validation.valid) { if (!validation.valid) {
showErrorToast(validation.error || 'Invalid file'); showErrorToast(validation.error || 'Invalid file');
if (fileInputRef.current) { if (fileInputRef.current) {

View file

@ -1,6 +1,7 @@
import { Attachment, AttachmentType } from '../entities/Attachment'; import { Attachment, AttachmentType } from '../entities/Attachment';
import { getApiPath } from '../config/paths'; import { getApiPath } from '../config/paths';
import { getCsrfToken } from './csrfService'; import { getCsrfToken } from './csrfService';
import { getServerConfig } from './configService';
/** /**
* Upload a file attachment to a task * Upload a file attachment to a task
@ -146,13 +147,15 @@ export function formatFileSize(bytes: number): string {
/** /**
* Validate file before upload * Validate file before upload
*/ */
export function validateFile(file: File): { valid: boolean; error?: string } { export async function validateFile(
// Check file size (10MB max) file: File
const maxSize = 10 * 1024 * 1024; ): Promise<{ valid: boolean; error?: string }> {
const config = await getServerConfig();
const maxSize = config.fileUploadLimitMB * 1024 * 1024;
if (file.size > maxSize) { if (file.size > maxSize) {
return { return {
valid: false, valid: false,
error: 'File size exceeds 10MB limit', error: `File size exceeds ${config.fileUploadLimitMB}MB limit`,
}; };
} }

View file

@ -0,0 +1,29 @@
import { getApiPath } from '../config/paths';
interface ServerConfig {
fileUploadLimitMB: number;
}
let cachedConfig: ServerConfig | null = null;
export async function getServerConfig(): Promise<ServerConfig> {
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;
}