tududi/backend/app.js
Chris ea78e81321
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
2026-04-27 13:35:02 +03:00

407 lines
13 KiB
JavaScript

require('dotenv').config();
const express = require('express');
const path = require('path');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const session = require('express-session');
const SequelizeStore = require('connect-session-sequelize')(session.Store);
const { sequelize } = require('./models');
const {
initializeTelegramPolling,
} = require('./modules/telegram/telegramInitializer');
const taskScheduler = require('./modules/tasks/taskScheduler');
const { setConfig, getConfig } = require('./config/config');
const config = getConfig();
const API_VERSION = process.env.API_VERSION || 'v1';
const API_BASE_PATH = `/api/${API_VERSION}`;
const app = express();
if (config.trustProxy !== false) {
app.set('trust proxy', config.trustProxy);
console.log(`[Trust Proxy] Enabled with value:`, config.trustProxy);
} else {
console.log(`[Trust Proxy] Disabled (value: false)`);
}
console.log(`[Trust Proxy] Express setting confirmed:`, app.get('trust proxy'));
// Session store
const sessionStore = new SequelizeStore({
db: sequelize,
});
// Middlewares
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
upgradeInsecureRequests:
process.env.DISABLE_HSTS === 'true' ? null : [],
},
},
hsts:
config.production && process.env.DISABLE_HSTS !== 'true'
? {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
}
: false,
})
);
app.use(compression());
app.use(morgan('combined'));
// CORS configuration
app.use(
cors({
origin: config.allowedOrigins,
credentials: true,
methods: [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'OPTIONS',
'PROPFIND',
'REPORT',
],
allowedHeaders: [
'Authorization',
'Content-Type',
'Accept',
'X-Requested-With',
'Depth',
'If-Match',
'If-None-Match',
],
exposedHeaders: ['Content-Type', 'ETag', 'DAV', 'Allow'],
maxAge: 1728000,
preflightContinue: true,
})
);
// Body parsing (skip for CalDAV routes which need raw body access)
app.use((req, res, next) => {
const isCalDAVPath =
req.path.startsWith('/caldav/') ||
req.path.startsWith('/.well-known/caldav');
if (isCalDAVPath) {
return next();
}
express.json({ limit: `${config.fileUploadLimitMB}mb` })(req, res, next);
});
app.use((req, res, next) => {
const isCalDAVPath =
req.path.startsWith('/caldav/') ||
req.path.startsWith('/.well-known/caldav');
if (isCalDAVPath) {
return next();
}
express.urlencoded({
extended: true,
limit: `${config.fileUploadLimitMB}mb`,
})(req, res, next);
});
// CalDAV routes (registered after conditional body parsers)
const caldavRoutes = require('./modules/caldav/routes');
app.use(caldavRoutes);
// Session configuration
const sessionMiddleware = session({
secret: config.secret,
store: sessionStore,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== 'false' && config.production,
maxAge: 2592000000, // 30 days
sameSite: 'lax',
},
});
app.use(sessionMiddleware);
// CSRF protection using lusca (CodeQL recommended library)
const lusca = require('lusca');
const { csrfMiddleware } = require('./middleware/csrf');
// Pre-check middleware to exempt test/Bearer requests before lusca runs
app.use((req, res, next) => {
// Public unauthenticated endpoints that should bypass CSRF
// These are routes that don't require authentication and are used during login/registration
const publicPaths = [
'/api/login',
'/api/register',
'/api/verify-email',
'/api/version',
'/api/registration-status',
'/api/health',
];
const isPublicPath = publicPaths.some((path) => req.path === path);
const isOidcPath = req.path.startsWith('/api/oidc/');
const isFeatureFlagsPath = req.path.startsWith('/api/feature-flags');
const isCalDAVPath =
req.path.startsWith('/caldav/') ||
req.path.startsWith('/.well-known/caldav');
// Mark exempt requests so lusca wrapper can skip them
if (
process.env.NODE_ENV === 'test' ||
req.headers.authorization?.startsWith('Bearer ') ||
isPublicPath ||
isOidcPath ||
isFeatureFlagsPath ||
isCalDAVPath
) {
req._csrfExempt = true;
}
next();
});
// Apply lusca CSRF - wrapped to check exemption flag
// Only apply to state-changing methods (POST, PUT, PATCH, DELETE)
app.use((req, res, next) => {
const statefulMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (req._csrfExempt || !statefulMethods.includes(req.method)) {
return next();
}
return csrfMiddleware(req, res, next);
});
// Static files
if (config.production) {
app.use(express.static(path.join(__dirname, 'dist')));
} else {
app.use(express.static('public'));
}
// Serve locales
if (config.production) {
app.use('/locales', express.static(path.join(__dirname, 'dist/locales')));
} else {
app.use(
'/locales',
express.static(path.join(__dirname, '../public/locales'))
);
}
// Serve uploaded files
const registerUploadsStatic = (basePath) => {
app.use(`${basePath}/uploads`, express.static(config.uploadPath));
};
registerUploadsStatic('/api');
if (API_VERSION && API_BASE_PATH !== '/api') {
registerUploadsStatic(API_BASE_PATH);
}
// Authentication middleware
const { requireAuth } = require('./middleware/auth');
const { logError } = require('./services/logService');
// Rate limiting middleware
const {
apiLimiter,
authenticatedApiLimiter,
} = require('./middleware/rateLimiter');
// Error handler for modular architecture
const errorHandler = require('./shared/middleware/errorHandler');
// Modular routes
const adminModule = require('./modules/admin');
const areasModule = require('./modules/areas');
const authModule = require('./modules/auth');
const backupModule = require('./modules/backup');
const featureFlagsModule = require('./modules/feature-flags');
const habitsModule = require('./modules/habits');
const inboxModule = require('./modules/inbox');
const notesModule = require('./modules/notes');
const notificationsModule = require('./modules/notifications');
const projectsModule = require('./modules/projects');
const quotesModule = require('./modules/quotes');
const searchModule = require('./modules/search');
const sharesModule = require('./modules/shares');
const tagsModule = require('./modules/tags');
const tasksModule = require('./modules/tasks');
const telegramModule = require('./modules/telegram');
const urlModule = require('./modules/url');
const usersModule = require('./modules/users');
const viewsModule = require('./modules/views');
const mcpModule = require('./modules/mcp');
const oidcModule = require('./modules/oidc');
// Swagger documentation - enabled by default, protected by authentication
// Mounted on /api-docs to avoid conflicts with API routes
if (config.swagger.enabled) {
const swaggerUi = require('swagger-ui-express');
const swaggerSpec = require('./config/swagger');
const swaggerUiOptions = {
customSiteTitle: 'Tududi API Documentation',
customfavIcon: '/favicon.ico',
customCss: '.swagger-ui .topbar { display: none }',
swaggerOptions: {
url: '/api-docs/swagger.json',
},
};
// Expose on /api-docs, protected by authentication
app.use('/api-docs', requireAuth, swaggerUi.serve);
app.get('/api-docs/swagger.json', requireAuth, (req, res) =>
res.json(swaggerSpec)
);
app.get(
'/api-docs',
requireAuth,
swaggerUi.serveFiles(swaggerSpec, swaggerUiOptions),
swaggerUi.setup(swaggerSpec, swaggerUiOptions)
);
}
// Apply rate limiting to API routes
// Use both limiters: apiLimiter for unauthenticated, authenticatedApiLimiter for authenticated
// Each has skip logic to handle their specific use case
const registerRateLimiting = (basePath) => {
app.use(basePath, apiLimiter);
app.use(basePath, authenticatedApiLimiter);
};
const rateLimitPath =
API_VERSION && API_BASE_PATH !== '/api' ? API_BASE_PATH : '/api';
registerRateLimiting(rateLimitPath);
// Health check (before auth middleware) - ensure it's completely bypassed
const registerHealthCheck = (basePath) => {
app.get(`${basePath}/health`, (req, res) => {
res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: config.environment,
trustProxy: config.trustProxy,
});
});
};
const healthPaths = new Set(['/api']);
if (API_VERSION && API_BASE_PATH !== '/api') {
healthPaths.add(API_BASE_PATH);
}
healthPaths.forEach(registerHealthCheck);
const registerApiRoutes = (basePath) => {
app.use(basePath, authModule.routes);
app.use(basePath, featureFlagsModule.routes);
app.use(`${basePath}/oidc`, oidcModule.routes);
app.use(basePath, requireAuth);
app.use(basePath, tasksModule.routes);
app.use(basePath, habitsModule.routes);
app.use(basePath, projectsModule.routes);
app.use(basePath, adminModule.routes);
app.use(basePath, sharesModule.routes);
app.use(basePath, areasModule.routes);
app.use(basePath, notesModule.routes);
app.use(basePath, tagsModule.routes);
app.use(basePath, usersModule.routes);
app.use(basePath, inboxModule.routes);
app.use(basePath, urlModule.routes);
app.use(basePath, telegramModule.routes);
app.use(basePath, quotesModule.routes);
app.use(basePath, backupModule.routes);
app.use(basePath, searchModule.routes);
app.use(basePath, viewsModule.routes);
app.use(basePath, notificationsModule.routes);
app.use(basePath, mcpModule.routes);
};
// Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility
// The requireAuth middleware is applied once per base path, preventing the auth loop
const routeBases = new Set(['/api']);
if (API_VERSION && API_BASE_PATH !== '/api') {
routeBases.add(API_BASE_PATH);
}
routeBases.forEach(registerApiRoutes);
// SPA fallback
app.get('*', (req, res) => {
if (
!req.path.startsWith('/api/') &&
!req.path.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)
) {
if (config.production) {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
} else {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
}
} else {
res.status(404).json({
error: 'Not Found',
message: 'The requested resource could not be found.',
});
}
});
// Error handling middleware (handles AppError and Sequelize errors)
app.use(errorHandler);
// Initialize database and start server
async function startServer() {
try {
// Create session store table
await sessionStore.sync();
// Initialize Telegram polling after database is ready
await initializeTelegramPolling();
// Initialize task scheduler
await taskScheduler.initialize();
// Initialize CalDAV sync scheduler
const caldavSyncScheduler = require('./modules/caldav/services/sync-scheduler');
await caldavSyncScheduler.initialize();
// Validate authentication configuration
const { validateAuthConfiguration } = require('./config/authConfig');
validateAuthConfiguration();
const server = app.listen(config.port, config.host, () => {
console.log(`Server running on port ${config.port}`);
console.log(`Server listening on http://localhost:${config.port}`);
});
server.on('error', (err) => {
console.error('Server error:', err);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
if (require.main === module) {
startServer();
}
module.exports = app;