* 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
309 lines
9.8 KiB
JavaScript
309 lines
9.8 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { getConfig } = require('../../config/config');
|
|
const config = getConfig();
|
|
const { TaskAttachment, Task } = require('../../models');
|
|
const { uid } = require('../../utils/uid');
|
|
const { logError } = require('../../services/logService');
|
|
const {
|
|
validateFileType,
|
|
deleteFileFromDisk,
|
|
getFileUrl,
|
|
} = require('../../utils/attachment-utils');
|
|
const { getAuthenticatedUserId } = require('../../utils/request-utils');
|
|
const permissionsService = require('../../services/permissionsService');
|
|
const {
|
|
createResourceLimiter,
|
|
authenticatedApiLimiter,
|
|
} = require('../../middleware/rateLimiter');
|
|
|
|
const router = express.Router();
|
|
|
|
// Ensure authenticated
|
|
router.use((req, res, next) => {
|
|
const userId = getAuthenticatedUserId(req);
|
|
if (!userId) {
|
|
return res.status(401).json({ error: 'Authentication required' });
|
|
}
|
|
req.authUserId = userId;
|
|
next();
|
|
});
|
|
|
|
// Configure multer for file uploads
|
|
const storage = multer.diskStorage({
|
|
destination: function (req, file, cb) {
|
|
const uploadDir = path.join(config.uploadPath, 'tasks');
|
|
if (!fs.existsSync(uploadDir)) {
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
}
|
|
cb(null, uploadDir);
|
|
},
|
|
filename: function (req, file, cb) {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
|
cb(null, 'task-' + uniqueSuffix + path.extname(file.originalname));
|
|
},
|
|
});
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
limits: {
|
|
fileSize: config.fileUploadLimitMB * 1024 * 1024,
|
|
},
|
|
fileFilter: function (req, file, cb) {
|
|
if (validateFileType(file.mimetype)) {
|
|
return cb(null, true);
|
|
} else {
|
|
cb(new Error('File type not allowed'));
|
|
}
|
|
},
|
|
});
|
|
|
|
// Upload attachment to task
|
|
router.post(
|
|
'/upload/task-attachment',
|
|
createResourceLimiter,
|
|
upload.single('file'),
|
|
async (req, res) => {
|
|
try {
|
|
const { taskUid } = req.body;
|
|
const userId = req.authUserId;
|
|
|
|
if (!taskUid) {
|
|
// Clean up uploaded file
|
|
if (req.file) {
|
|
await deleteFileFromDisk(req.file.path);
|
|
}
|
|
return res.status(400).json({ error: 'Task UID is required' });
|
|
}
|
|
|
|
// Find task
|
|
const task = await Task.findOne({ where: { uid: taskUid } });
|
|
if (!task) {
|
|
// Clean up uploaded file
|
|
if (req.file) {
|
|
await deleteFileFromDisk(req.file.path);
|
|
}
|
|
return res.status(404).json({ error: 'Task not found' });
|
|
}
|
|
|
|
// Check if user has write access to the task (includes shared projects)
|
|
const access = await permissionsService.getAccess(
|
|
userId,
|
|
'task',
|
|
taskUid
|
|
);
|
|
const LEVELS = { none: 0, ro: 1, rw: 2, admin: 3 };
|
|
if (LEVELS[access] < LEVELS.rw) {
|
|
// Clean up uploaded file
|
|
if (req.file) {
|
|
await deleteFileFromDisk(req.file.path);
|
|
}
|
|
return res
|
|
.status(403)
|
|
.json({ error: 'Not authorized to upload to this task' });
|
|
}
|
|
|
|
// Check attachment count limit (20 max)
|
|
const attachmentCount = await TaskAttachment.count({
|
|
where: { task_id: task.id },
|
|
});
|
|
|
|
if (attachmentCount >= 20) {
|
|
// Clean up uploaded file
|
|
if (req.file) {
|
|
await deleteFileFromDisk(req.file.path);
|
|
}
|
|
return res.status(400).json({
|
|
error: 'Maximum 20 attachments allowed per task',
|
|
});
|
|
}
|
|
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No file uploaded' });
|
|
}
|
|
|
|
// Create attachment record
|
|
const attachment = await TaskAttachment.create({
|
|
uid: uid(),
|
|
task_id: task.id,
|
|
user_id: userId,
|
|
original_filename: req.file.originalname,
|
|
stored_filename: req.file.filename,
|
|
file_size: req.file.size,
|
|
mime_type: req.file.mimetype,
|
|
file_path: `tasks/${req.file.filename}`,
|
|
});
|
|
|
|
// Return attachment with file URL
|
|
const attachmentData = {
|
|
...attachment.toJSON(),
|
|
file_url: getFileUrl(req.file.filename),
|
|
};
|
|
|
|
res.status(201).json(attachmentData);
|
|
} catch (error) {
|
|
logError('Error uploading attachment:', error);
|
|
|
|
// Clean up uploaded file on error
|
|
if (req.file) {
|
|
await deleteFileFromDisk(req.file.path);
|
|
}
|
|
|
|
res.status(500).json({
|
|
error: 'Failed to upload attachment',
|
|
details: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// Get all attachments for a task
|
|
router.get('/tasks/:taskUid/attachments', async (req, res) => {
|
|
try {
|
|
const { taskUid } = req.params;
|
|
const userId = req.authUserId;
|
|
|
|
// Find task
|
|
const task = await Task.findOne({ where: { uid: taskUid } });
|
|
if (!task) {
|
|
return res.status(404).json({ error: 'Task not found' });
|
|
}
|
|
|
|
// Check if user has read access to the task (includes shared projects)
|
|
const access = await permissionsService.getAccess(
|
|
userId,
|
|
'task',
|
|
taskUid
|
|
);
|
|
const LEVELS = { none: 0, ro: 1, rw: 2, admin: 3 };
|
|
if (LEVELS[access] < LEVELS.ro) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: 'Not authorized to view this task' });
|
|
}
|
|
|
|
// Get attachments
|
|
const attachments = await TaskAttachment.findAll({
|
|
where: { task_id: task.id },
|
|
order: [['created_at', 'ASC']],
|
|
});
|
|
|
|
// Add file URLs
|
|
const attachmentsWithUrls = attachments.map((att) => ({
|
|
...att.toJSON(),
|
|
file_url: getFileUrl(att.stored_filename),
|
|
}));
|
|
|
|
res.json(attachmentsWithUrls);
|
|
} catch (error) {
|
|
logError('Error fetching attachments:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to fetch attachments',
|
|
details: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete an attachment
|
|
router.delete(
|
|
'/tasks/:taskUid/attachments/:attachmentUid',
|
|
createResourceLimiter,
|
|
async (req, res) => {
|
|
try {
|
|
const { taskUid, attachmentUid } = req.params;
|
|
const userId = req.authUserId;
|
|
|
|
// Find task
|
|
const task = await Task.findOne({ where: { uid: taskUid } });
|
|
if (!task) {
|
|
return res.status(404).json({ error: 'Task not found' });
|
|
}
|
|
|
|
// Check if user has write access to the task (includes shared projects)
|
|
const access = await permissionsService.getAccess(
|
|
userId,
|
|
'task',
|
|
taskUid
|
|
);
|
|
const LEVELS = { none: 0, ro: 1, rw: 2, admin: 3 };
|
|
if (LEVELS[access] < LEVELS.rw) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: 'Not authorized to modify this task' });
|
|
}
|
|
|
|
// Find attachment
|
|
const attachment = await TaskAttachment.findOne({
|
|
where: { uid: attachmentUid, task_id: task.id },
|
|
});
|
|
|
|
if (!attachment) {
|
|
return res.status(404).json({ error: 'Attachment not found' });
|
|
}
|
|
|
|
// Delete file from disk
|
|
const filePath = path.join(config.uploadPath, attachment.file_path);
|
|
await deleteFileFromDisk(filePath);
|
|
|
|
// Delete database record
|
|
await attachment.destroy();
|
|
|
|
res.json({ message: 'Attachment deleted successfully' });
|
|
} catch (error) {
|
|
logError('Error deleting attachment:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to delete attachment',
|
|
details: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
// Download an attachment
|
|
router.get(
|
|
'/attachments/:attachmentUid/download',
|
|
authenticatedApiLimiter,
|
|
async (req, res) => {
|
|
try {
|
|
const { attachmentUid } = req.params;
|
|
const userId = req.authUserId;
|
|
|
|
// Find attachment
|
|
const attachment = await TaskAttachment.findOne({
|
|
where: { uid: attachmentUid },
|
|
include: [{ model: Task, required: true }],
|
|
});
|
|
|
|
if (!attachment) {
|
|
return res.status(404).json({ error: 'Attachment not found' });
|
|
}
|
|
|
|
// Check if user has read access to the task (includes shared projects)
|
|
const access = await permissionsService.getAccess(
|
|
userId,
|
|
'task',
|
|
attachment.Task.uid
|
|
);
|
|
const LEVELS = { none: 0, ro: 1, rw: 2, admin: 3 };
|
|
if (LEVELS[access] < LEVELS.ro) {
|
|
return res
|
|
.status(403)
|
|
.json({ error: 'Not authorized to download this file' });
|
|
}
|
|
|
|
// Send file
|
|
const filePath = path.join(config.uploadPath, attachment.file_path);
|
|
res.download(filePath, attachment.original_filename);
|
|
} catch (error) {
|
|
logError('Error downloading attachment:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to download attachment',
|
|
details: error.message,
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
module.exports = router;
|