Reverted New UI Design of WeKan v8.29 and added more fixes and performance improvements.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2026-02-08 00:48:39 +02:00
parent d152d8fc1b
commit 1b8b8d2eef
196 changed files with 17659 additions and 10028 deletions

View file

@ -150,7 +150,7 @@ if (Meteor.isServer) {
readStream.on('end', () => {
const fileBuffer = Buffer.concat(chunks);
const base64Data = fileBuffer.toString('base64');
resolve({
success: true,
attachmentId: attachmentId,
@ -200,7 +200,7 @@ if (Meteor.isServer) {
}
const attachments = ReactiveCache.getAttachments(query);
const attachmentList = attachments.map(attachment => {
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
return {
@ -438,7 +438,7 @@ if (Meteor.isServer) {
try {
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
return {
success: true,
attachmentId: attachment._id,

View file

@ -8,6 +8,7 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { check } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import Attachments from '/models/attachments';
import { AttachmentMigrationStatus } from './attachmentMigrationStatus';
// Reactive variables for tracking migration progress
const migrationProgress = new ReactiveVar(0);
@ -28,7 +29,21 @@ class AttachmentMigrationService {
* @returns {boolean} - True if board has been migrated
*/
isBoardMigrated(boardId) {
return migratedBoards.has(boardId);
const isMigrated = migratedBoards.has(boardId);
// Update status collection for pub/sub
AttachmentMigrationStatus.upsert(
{ boardId },
{
$set: {
boardId,
isMigrated,
updatedAt: new Date()
}
}
);
return isMigrated;
}
/**
@ -44,7 +59,7 @@ class AttachmentMigrationService {
}
console.log(`Starting attachment migration for board: ${boardId}`);
// Get all attachments for the board
const attachments = Attachments.find({
'meta.boardId': boardId
@ -63,12 +78,12 @@ class AttachmentMigrationService {
await this.migrateAttachment(attachment);
this.migrationCache.set(attachment._id, true);
}
migratedCount++;
const progress = Math.round((migratedCount / totalAttachments) * 100);
migrationProgress.set(progress);
migrationStatus.set(`Migrated ${migratedCount}/${totalAttachments} attachments...`);
} catch (error) {
console.error(`Error migrating attachment ${attachment._id}:`, error);
}
@ -86,6 +101,23 @@ class AttachmentMigrationService {
console.log(`Attachment migration completed for board: ${boardId}`);
console.log(`Marked board ${boardId} as migrated`);
// Update status collection
AttachmentMigrationStatus.upsert(
{ boardId },
{
$set: {
boardId,
isMigrated: true,
totalAttachments,
migratedAttachments: totalAttachments,
unconvertedAttachments: 0,
progress: 100,
status: 'completed',
updatedAt: new Date()
}
}
);
return { success: true, message: 'Migration completed' };
} catch (error) {
@ -106,8 +138,8 @@ class AttachmentMigrationService {
}
// Check if attachment has old structure
return !attachment.meta ||
!attachment.meta.cardId ||
return !attachment.meta ||
!attachment.meta.cardId ||
!attachment.meta.boardId ||
!attachment.meta.listId;
}
@ -188,6 +220,25 @@ class AttachmentMigrationService {
const progress = migrationProgress.get();
const status = migrationStatus.get();
const unconverted = this.getUnconvertedAttachments(boardId);
const total = Attachments.find({ 'meta.boardId': boardId }).count();
const migratedCount = total - unconverted.length;
// Update status collection for pub/sub
AttachmentMigrationStatus.upsert(
{ boardId },
{
$set: {
boardId,
totalAttachments: total,
migratedAttachments: migratedCount,
unconvertedAttachments: unconverted.length,
progress: total > 0 ? Math.round((migratedCount / total) * 100) : 0,
status: status || 'idle',
isMigrated: unconverted.length === 0,
updatedAt: new Date()
}
}
);
return {
progress,
@ -203,20 +254,20 @@ const attachmentMigrationService = new AttachmentMigrationService();
Meteor.methods({
async 'attachmentMigration.migrateBoardAttachments'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found');
}
const user = ReactiveCache.getUser(this.userId);
const isBoardAdmin = board.hasAdmin(this.userId);
const isInstanceAdmin = user && user.isAdmin;
if (!isBoardAdmin && !isInstanceAdmin) {
throw new Meteor.Error('not-authorized', 'You must be a board admin or instance admin to perform this action.');
}
@ -226,11 +277,11 @@ Meteor.methods({
'attachmentMigration.getProgress'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const board = ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
@ -241,11 +292,11 @@ Meteor.methods({
'attachmentMigration.getUnconvertedAttachments'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const board = ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
@ -256,11 +307,11 @@ Meteor.methods({
'attachmentMigration.isBoardMigrated'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const board = ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');

View file

@ -0,0 +1,22 @@
import { Mongo } from 'meteor/mongo';
// Server-side collection for attachment migration status
export const AttachmentMigrationStatus = new Mongo.Collection('attachmentMigrationStatus');
// Allow/Deny rules
// This collection is server-only and should not be modified by clients
// Allow server-side operations (when userId is undefined) but deny all client operations
if (Meteor.isServer) {
AttachmentMigrationStatus.allow({
insert: (userId) => !userId,
update: (userId) => !userId,
remove: (userId) => !userId,
});
}
// Create indexes for better query performance
Meteor.startup(() => {
AttachmentMigrationStatus._collection.createIndexAsync({ boardId: 1 });
AttachmentMigrationStatus._collection.createIndexAsync({ userId: 1, boardId: 1 });
AttachmentMigrationStatus._collection.createIndexAsync({ updatedAt: -1 });
});

View file

@ -63,7 +63,7 @@ class BoardMigrationDetector {
isSystemIdle() {
const resources = cronJobStorage.getSystemResources();
const queueStats = cronJobStorage.getQueueStats();
// Check if no jobs are running
if (queueStats.running > 0) {
return false;
@ -120,7 +120,7 @@ class BoardMigrationDetector {
try {
// Scanning for unmigrated boards
// Get all boards from the database
const boards = this.getAllBoards();
const unmigrated = [];
@ -155,7 +155,7 @@ class BoardMigrationDetector {
if (typeof Boards !== 'undefined') {
return Boards.find({}, { fields: { _id: 1, title: 1, createdAt: 1, modifiedAt: 1 } }).fetch();
}
// Fallback: return empty array if Boards collection not available
return [];
} catch (error) {
@ -171,14 +171,14 @@ class BoardMigrationDetector {
try {
// Check if board has been migrated by looking for migration markers
const migrationMarkers = this.getMigrationMarkers(board._id);
// Check for specific migration indicators
const needsListMigration = !migrationMarkers.listsMigrated;
const needsAttachmentMigration = !migrationMarkers.attachmentsMigrated;
const needsSwimlaneMigration = !migrationMarkers.swimlanesMigrated;
return needsListMigration || needsAttachmentMigration || needsSwimlaneMigration;
} catch (error) {
console.error(`Error checking migration status for board ${board._id}:`, error);
return false;
@ -192,7 +192,7 @@ class BoardMigrationDetector {
try {
// Check if board has migration metadata
const board = Boards.findOne(boardId, { fields: { migrationMarkers: 1 } });
if (!board || !board.migrationMarkers) {
return {
listsMigrated: false,
@ -230,7 +230,7 @@ class BoardMigrationDetector {
// Create migration job for this board
const jobId = `board_migration_${board._id}_${Date.now()}`;
// Add to job queue with high priority
cronJobStorage.addToQueue(jobId, 'board_migration', 1, {
boardId: board._id,
@ -292,14 +292,14 @@ class BoardMigrationDetector {
getBoardMigrationStatus(boardId) {
const unmigrated = unmigratedBoards.get();
const isUnmigrated = unmigrated.some(b => b._id === boardId);
if (!isUnmigrated) {
return { needsMigration: false, reason: 'Board is already migrated' };
}
const migrationMarkers = this.getMigrationMarkers(boardId);
const needsMigration = !migrationMarkers.listsMigrated ||
!migrationMarkers.attachmentsMigrated ||
const needsMigration = !migrationMarkers.listsMigrated ||
!migrationMarkers.attachmentsMigrated ||
!migrationMarkers.swimlanesMigrated;
return {
@ -352,7 +352,7 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.getMigrationStats();
},
@ -360,38 +360,38 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.forceScan();
},
'boardMigration.getBoardStatus'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.getBoardMigrationStatus(boardId);
},
'boardMigration.markAsMigrated'(boardId, migrationType) {
check(boardId, String);
check(migrationType, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.markBoardAsMigrated(boardId, migrationType);
},
'boardMigration.startBoardMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return boardMigrationDetector.startBoardMigration(boardId);
},
@ -399,7 +399,7 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
// Find boards that have migration markers but no migrationVersion
const stuckBoards = Boards.find({
'migrationMarkers.fullMigrationCompleted': true,
@ -408,15 +408,15 @@ Meteor.methods({
{ migrationVersion: { $lt: 1 } }
]
}).fetch();
let fixedCount = 0;
stuckBoards.forEach(board => {
try {
Boards.update(board._id, {
$set: {
Boards.update(board._id, {
$set: {
migrationVersion: 1,
'migrationMarkers.lastMigration': new Date()
}
}
});
fixedCount++;
console.log(`Fixed stuck board: ${board._id} (${board.title})`);
@ -424,7 +424,7 @@ Meteor.methods({
console.error(`Error fixing board ${board._id}:`, error);
}
});
return {
message: `Fixed ${fixedCount} stuck boards`,
fixedCount,

View file

@ -12,6 +12,38 @@ export const CronJobSteps = new Mongo.Collection('cronJobSteps');
export const CronJobQueue = new Mongo.Collection('cronJobQueue');
export const CronJobErrors = new Mongo.Collection('cronJobErrors');
// Allow/Deny rules
// These collections are server-only and should not be modified by clients
// Allow server-side operations (when userId is undefined) but deny all client operations
if (Meteor.isServer) {
// Helper function to check if operation is server-only
const isServerOperation = (userId) => !userId;
CronJobStatus.allow({
insert: isServerOperation,
update: isServerOperation,
remove: isServerOperation,
});
CronJobSteps.allow({
insert: isServerOperation,
update: isServerOperation,
remove: isServerOperation,
});
CronJobQueue.allow({
insert: isServerOperation,
update: isServerOperation,
remove: isServerOperation,
});
CronJobErrors.allow({
insert: isServerOperation,
update: isServerOperation,
remove: isServerOperation,
});
}
// Indexes for performance
if (Meteor.isServer) {
Meteor.startup(async () => {
@ -55,7 +87,7 @@ class CronJobStorage {
if (envLimit) {
return parseInt(envLimit, 10);
}
// Auto-detect based on CPU cores
const os = require('os');
const cpuCores = os.cpus().length;
@ -68,7 +100,7 @@ class CronJobStorage {
saveJobStatus(jobId, jobData) {
const now = new Date();
const existingJob = CronJobStatus.findOne({ jobId });
if (existingJob) {
CronJobStatus.update(
{ jobId },
@ -111,7 +143,7 @@ class CronJobStorage {
saveJobStep(jobId, stepIndex, stepData) {
const now = new Date();
const existingStep = CronJobSteps.findOne({ jobId, stepIndex });
if (existingStep) {
CronJobSteps.update(
{ jobId, stepIndex },
@ -159,7 +191,7 @@ class CronJobStorage {
saveJobError(jobId, errorData) {
const now = new Date();
const { stepId, stepIndex, error, severity = 'error', context = {} } = errorData;
CronJobErrors.insert({
jobId,
stepId,
@ -177,15 +209,15 @@ class CronJobStorage {
*/
getJobErrors(jobId, options = {}) {
const { limit = 100, severity = null } = options;
const query = { jobId };
if (severity) {
query.severity = severity;
}
return CronJobErrors.find(query, {
return CronJobErrors.find(query, {
sort: { createdAt: -1 },
limit
limit
}).fetch();
}
@ -193,9 +225,9 @@ class CronJobStorage {
* Get all recent errors across all jobs
*/
getAllRecentErrors(limit = 50) {
return CronJobErrors.find({}, {
return CronJobErrors.find({}, {
sort: { createdAt: -1 },
limit
limit
}).fetch();
}
@ -211,13 +243,13 @@ class CronJobStorage {
*/
addToQueue(jobId, jobType, priority = 5, jobData = {}) {
const now = new Date();
// Check if job already exists in queue
const existingJob = CronJobQueue.findOne({ jobId });
if (existingJob) {
return existingJob._id;
}
return CronJobQueue.insert({
jobId,
jobType,
@ -269,26 +301,26 @@ class CronJobStorage {
*/
getSystemResources() {
const os = require('os');
// Get CPU usage (simplified)
const cpus = os.cpus();
let totalIdle = 0;
let totalTick = 0;
cpus.forEach(cpu => {
for (const type in cpu.times) {
totalTick += cpu.times[type];
}
totalIdle += cpu.times.idle;
});
const cpuUsage = 100 - Math.round(100 * totalIdle / totalTick);
// Get memory usage
const totalMem = os.totalmem();
const freeMem = os.freemem();
const memoryUsage = Math.round(100 * (totalMem - freeMem) / totalMem);
return {
cpuUsage,
memoryUsage,
@ -304,21 +336,21 @@ class CronJobStorage {
canStartNewJob() {
const resources = this.getSystemResources();
const runningJobs = CronJobQueue.find({ status: 'running' }).count();
// Check CPU and memory thresholds
if (resources.cpuUsage > this.cpuThreshold) {
return { canStart: false, reason: 'CPU usage too high' };
}
if (resources.memoryUsage > this.memoryThreshold) {
return { canStart: false, reason: 'Memory usage too high' };
}
// Check concurrent job limit
if (runningJobs >= this.maxConcurrentJobs) {
return { canStart: false, reason: 'Maximum concurrent jobs reached' };
}
return { canStart: true, reason: 'System can handle new job' };
}
@ -331,7 +363,7 @@ class CronJobStorage {
const running = CronJobQueue.find({ status: 'running' }).count();
const completed = CronJobQueue.find({ status: 'completed' }).count();
const failed = CronJobQueue.find({ status: 'failed' }).count();
return {
total,
pending,
@ -348,25 +380,25 @@ class CronJobStorage {
cleanupOldJobs(daysOld = 7) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
// Remove old completed jobs from queue
const removedQueue = CronJobQueue.remove({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
// Remove old job statuses
const removedStatus = CronJobStatus.remove({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
// Remove old job steps
const removedSteps = CronJobSteps.remove({
status: 'completed',
updatedAt: { $lt: cutoffDate }
});
return {
removedQueue,
removedStatus,
@ -380,7 +412,7 @@ class CronJobStorage {
resumeIncompleteJobs() {
const incompleteJobs = this.getIncompleteJobs();
const resumedJobs = [];
incompleteJobs.forEach(job => {
// Reset running jobs to pending
if (job.status === 'running') {
@ -391,14 +423,14 @@ class CronJobStorage {
});
resumedJobs.push(job.jobId);
}
// Add to queue if not already there
const queueJob = CronJobQueue.findOne({ jobId: job.jobId });
if (!queueJob) {
this.addToQueue(job.jobId, job.jobType || 'unknown', job.priority || 5, job);
}
});
return resumedJobs;
}
@ -408,7 +440,7 @@ class CronJobStorage {
getJobProgress(jobId) {
const steps = this.getJobSteps(jobId);
if (steps.length === 0) return 0;
const completedSteps = steps.filter(step => step.status === 'completed').length;
return Math.round((completedSteps / steps.length) * 100);
}
@ -420,7 +452,7 @@ class CronJobStorage {
const jobStatus = this.getJobStatus(jobId);
const jobSteps = this.getJobSteps(jobId);
const progress = this.getJobProgress(jobId);
return {
...jobStatus,
steps: jobSteps,
@ -440,7 +472,7 @@ class CronJobStorage {
CronJobSteps.remove({});
CronJobQueue.remove({});
CronJobErrors.remove({});
console.log('All cron job data cleared from storage');
return { success: true, message: 'All cron job data cleared' };
} catch (error) {
@ -460,7 +492,7 @@ Meteor.startup(() => {
if (resumedJobs.length > 0) {
// Resumed incomplete cron jobs
}
// Cleanup old jobs
const cleanup = cronJobStorage.cleanupOldJobs();
if (cleanup.removedQueue > 0 || cleanup.removedStatus > 0 || cleanup.removedSteps > 0) {

File diff suppressed because it is too large Load diff

View file

@ -161,7 +161,7 @@ describe('attachmentApi authentication', function() {
describe('request handler DoS prevention', function() {
it('enforces timeout on hanging requests', function(done) {
this.timeout(5000);
const req = createMockReq({ 'x-user-id': 'user1', 'x-auth-token': 'token1' });
const res = createMockRes();

View file

@ -23,7 +23,7 @@ Meteor.methods({
if (process.env.DEBUG === 'true') {
console.log('Starting duplicate lists fix for all boards...');
}
const allBoards = Boards.find({}).fetch();
let totalFixed = 0;
let totalBoardsProcessed = 0;
@ -33,7 +33,7 @@ Meteor.methods({
const result = fixDuplicateListsForBoard(board._id);
totalFixed += result.fixed;
totalBoardsProcessed++;
if (result.fixed > 0 && process.env.DEBUG === 'true') {
console.log(`Fixed ${result.fixed} duplicate lists in board "${board.title}" (${board._id})`);
}
@ -45,7 +45,7 @@ Meteor.methods({
if (process.env.DEBUG === 'true') {
console.log(`Duplicate lists fix completed. Processed ${totalBoardsProcessed} boards, fixed ${totalFixed} duplicate lists.`);
}
return {
message: `Fixed ${totalFixed} duplicate lists across ${totalBoardsProcessed} boards`,
totalFixed,
@ -55,7 +55,7 @@ Meteor.methods({
'fixDuplicateLists.fixBoard'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
@ -74,13 +74,13 @@ function fixDuplicateListsForBoard(boardId) {
if (process.env.DEBUG === 'true') {
console.log(`Fixing duplicate lists for board ${boardId}...`);
}
// First, fix duplicate swimlanes
const swimlaneResult = fixDuplicateSwimlanes(boardId);
// Then, fix duplicate lists
const listResult = fixDuplicateLists(boardId);
return {
boardId,
fixedSwimlanes: swimlaneResult.fixed,
@ -193,7 +193,7 @@ function fixDuplicateLists(boardId) {
{ $set: { listId: keepList._id } },
{ multi: true }
);
// Remove duplicate list
Lists.remove(list._id);
fixed++;
@ -223,7 +223,7 @@ Meteor.methods({
for (const board of allBoards) {
const swimlanes = Swimlanes.find({ boardId: board._id }).fetch();
const lists = Lists.find({ boardId: board._id }).fetch();
// Check for duplicate swimlanes
const swimlaneGroups = {};
swimlanes.forEach(swimlane => {

View file

@ -15,21 +15,21 @@ Meteor.methods({
*/
'positionHistory.trackSwimlane'(swimlaneId) {
check(swimlaneId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const swimlane = Swimlanes.findOne(swimlaneId);
if (!swimlane) {
throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
}
const board = ReactiveCache.getBoard(swimlane.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return swimlane.trackOriginalPosition();
},
@ -38,21 +38,21 @@ Meteor.methods({
*/
'positionHistory.trackList'(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = Lists.findOne(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = ReactiveCache.getBoard(list.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return list.trackOriginalPosition();
},
@ -61,21 +61,21 @@ Meteor.methods({
*/
'positionHistory.trackCard'(cardId) {
check(cardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const card = Cards.findOne(cardId);
if (!card) {
throw new Meteor.Error('card-not-found', 'Card not found');
}
const board = ReactiveCache.getBoard(card.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return card.trackOriginalPosition();
},
@ -84,21 +84,21 @@ Meteor.methods({
*/
'positionHistory.getSwimlaneOriginalPosition'(swimlaneId) {
check(swimlaneId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const swimlane = Swimlanes.findOne(swimlaneId);
if (!swimlane) {
throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
}
const board = ReactiveCache.getBoard(swimlane.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return swimlane.getOriginalPosition();
},
@ -107,21 +107,21 @@ Meteor.methods({
*/
'positionHistory.getListOriginalPosition'(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = Lists.findOne(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = ReactiveCache.getBoard(list.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return list.getOriginalPosition();
},
@ -130,21 +130,21 @@ Meteor.methods({
*/
'positionHistory.getCardOriginalPosition'(cardId) {
check(cardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const card = Cards.findOne(cardId);
if (!card) {
throw new Meteor.Error('card-not-found', 'Card not found');
}
const board = ReactiveCache.getBoard(card.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return card.getOriginalPosition();
},
@ -153,21 +153,21 @@ Meteor.methods({
*/
'positionHistory.hasSwimlaneMoved'(swimlaneId) {
check(swimlaneId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const swimlane = Swimlanes.findOne(swimlaneId);
if (!swimlane) {
throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
}
const board = ReactiveCache.getBoard(swimlane.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return swimlane.hasMovedFromOriginalPosition();
},
@ -176,21 +176,21 @@ Meteor.methods({
*/
'positionHistory.hasListMoved'(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = Lists.findOne(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = ReactiveCache.getBoard(list.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return list.hasMovedFromOriginalPosition();
},
@ -199,21 +199,21 @@ Meteor.methods({
*/
'positionHistory.hasCardMoved'(cardId) {
check(cardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const card = Cards.findOne(cardId);
if (!card) {
throw new Meteor.Error('card-not-found', 'Card not found');
}
const board = ReactiveCache.getBoard(card.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return card.hasMovedFromOriginalPosition();
},
@ -222,21 +222,21 @@ Meteor.methods({
*/
'positionHistory.getSwimlaneDescription'(swimlaneId) {
check(swimlaneId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const swimlane = Swimlanes.findOne(swimlaneId);
if (!swimlane) {
throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
}
const board = ReactiveCache.getBoard(swimlane.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return swimlane.getOriginalPositionDescription();
},
@ -245,21 +245,21 @@ Meteor.methods({
*/
'positionHistory.getListDescription'(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = Lists.findOne(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = ReactiveCache.getBoard(list.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return list.getOriginalPositionDescription();
},
@ -268,21 +268,21 @@ Meteor.methods({
*/
'positionHistory.getCardDescription'(cardId) {
check(cardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const card = Cards.findOne(cardId);
if (!card) {
throw new Meteor.Error('card-not-found', 'Card not found');
}
const board = ReactiveCache.getBoard(card.boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return card.getOriginalPositionDescription();
},
@ -291,16 +291,16 @@ Meteor.methods({
*/
'positionHistory.getBoardHistory'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const board = ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
return PositionHistory.find({
boardId: boardId,
}, {
@ -314,20 +314,20 @@ Meteor.methods({
'positionHistory.getBoardHistoryByType'(boardId, entityType) {
check(boardId, String);
check(entityType, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const board = ReactiveCache.getBoard(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
throw new Meteor.Error('not-authorized', 'You do not have access to this board.');
}
if (!['swimlane', 'list', 'card'].includes(entityType)) {
throw new Meteor.Error('invalid-entity-type', 'Entity type must be swimlane, list, or card');
}
return PositionHistory.find({
boardId: boardId,
entityType: entityType,

View file

@ -1,14 +1,14 @@
/**
* Comprehensive Board Migration System
*
*
* This migration handles all database structure changes from previous Wekan versions
* to the current per-swimlane lists structure. It ensures:
*
*
* 1. All cards are visible with proper swimlaneId and listId
* 2. Lists are per-swimlane (no shared lists across swimlanes)
* 3. No empty lists are created
* 4. Handles various database structure versions from git history
*
*
* Supported versions and their database structures:
* - v7.94 and earlier: Shared lists across all swimlanes
* - v8.00-v8.02: Transition period with mixed structures
@ -66,7 +66,7 @@ class ComprehensiveBoardMigration {
*/
detectMigrationIssues(boardId) {
const issues = [];
try {
const cards = ReactiveCache.getCards({ boardId });
const lists = ReactiveCache.getLists({ boardId });
@ -178,7 +178,7 @@ class ComprehensiveBoardMigration {
const updateProgress = (stepName, stepProgress, stepStatus, stepDetails = null) => {
currentStep++;
const overallProgress = Math.round((currentStep / totalSteps) * 100);
const progressData = {
overallProgress,
currentStep: currentStep,
@ -206,7 +206,7 @@ class ComprehensiveBoardMigration {
issuesFound: results.steps.analyze.issueCount,
needsMigration: results.steps.analyze.needsMigration
});
// Step 2: Fix orphaned cards
updateProgress('fix_orphaned_cards', 0, 'Fixing orphaned cards...');
results.steps.fixOrphanedCards = await this.fixOrphanedCards(boardId, (progress, status) => {
@ -323,7 +323,7 @@ class ComprehensiveBoardMigration {
if (!card.listId) {
// Find or create a default list for this swimlane
const swimlaneId = updates.swimlaneId || card.swimlaneId;
let defaultList = lists.find(list =>
let defaultList = lists.find(list =>
list.swimlaneId === swimlaneId && list.title === 'Default'
);
@ -426,7 +426,7 @@ class ComprehensiveBoardMigration {
// Check if we already have a list with the same title in this swimlane
let targetList = existingLists.find(list => list.title === originalList.title);
if (!targetList) {
// Create a new list for this swimlane
const newListData = {
@ -508,12 +508,12 @@ class ComprehensiveBoardMigration {
for (const list of lists) {
const listCards = cards.filter(card => card.listId === list._id);
if (listCards.length === 0) {
// Remove empty list
Lists.remove(list._id);
listsRemoved++;
if (process.env.DEBUG === 'true') {
console.log(`Removed empty list: ${list.title} (${list._id})`);
}
@ -563,7 +563,7 @@ class ComprehensiveBoardMigration {
const avatarUrl = user.profile.avatarUrl;
let needsUpdate = false;
let cleanUrl = avatarUrl;
// Check if URL has problematic parameters
if (avatarUrl.includes('auth=false') || avatarUrl.includes('brokenIsFine=true')) {
// Remove problematic parameters
@ -573,13 +573,13 @@ class ComprehensiveBoardMigration {
cleanUrl = cleanUrl.replace(/\?$/g, '');
needsUpdate = true;
}
// Check if URL is using old CollectionFS format
if (avatarUrl.includes('/cfs/files/avatars/')) {
cleanUrl = cleanUrl.replace('/cfs/files/avatars/', '/cdn/storage/avatars/');
needsUpdate = true;
}
// Check if URL is missing the /cdn/storage/avatars/ prefix
if (avatarUrl.includes('avatars/') && !avatarUrl.includes('/cdn/storage/avatars/') && !avatarUrl.includes('/cfs/files/avatars/')) {
// This might be a relative URL, make it absolute
@ -588,7 +588,7 @@ class ComprehensiveBoardMigration {
needsUpdate = true;
}
}
if (needsUpdate) {
// Update user's avatar URL
Users.update(user._id, {
@ -597,7 +597,7 @@ class ComprehensiveBoardMigration {
modifiedAt: new Date()
}
});
avatarsFixed++;
}
}
@ -619,7 +619,7 @@ class ComprehensiveBoardMigration {
const attachmentUrl = attachment.url;
let needsUpdate = false;
let cleanUrl = attachmentUrl;
// Check if URL has problematic parameters
if (attachmentUrl.includes('auth=false') || attachmentUrl.includes('brokenIsFine=true')) {
// Remove problematic parameters
@ -629,26 +629,26 @@ class ComprehensiveBoardMigration {
cleanUrl = cleanUrl.replace(/\?$/g, '');
needsUpdate = true;
}
// Check if URL is using old CollectionFS format
if (attachmentUrl.includes('/cfs/files/attachments/')) {
cleanUrl = cleanUrl.replace('/cfs/files/attachments/', '/cdn/storage/attachments/');
needsUpdate = true;
}
// Check if URL has /original/ path that should be removed
if (attachmentUrl.includes('/original/')) {
cleanUrl = cleanUrl.replace(/\/original\/[^\/\?#]+/, '');
needsUpdate = true;
}
// If we have a file ID, generate a universal URL
const fileId = attachment._id;
if (fileId && !isUniversalFileUrl(cleanUrl, 'attachment')) {
cleanUrl = generateUniversalAttachmentUrl(fileId);
needsUpdate = true;
}
if (needsUpdate) {
// Update attachment URL
Attachments.update(attachment._id, {
@ -657,7 +657,7 @@ class ComprehensiveBoardMigration {
modifiedAt: new Date()
}
});
attachmentsFixed++;
}
}
@ -677,7 +677,7 @@ class ComprehensiveBoardMigration {
}
if (board.comprehensiveMigrationCompleted) {
return {
return {
status: 'completed',
completedAt: board.comprehensiveMigrationCompletedAt,
results: board.comprehensiveMigrationResults
@ -686,7 +686,7 @@ class ComprehensiveBoardMigration {
const needsMigration = this.needsMigration(boardId);
const issues = this.detectMigrationIssues(boardId);
return {
status: needsMigration ? 'needed' : 'not_needed',
issues,
@ -707,54 +707,54 @@ export const comprehensiveBoardMigration = new ComprehensiveBoardMigration();
Meteor.methods({
'comprehensiveBoardMigration.check'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return comprehensiveBoardMigration.getMigrationStatus(boardId);
},
'comprehensiveBoardMigration.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = ReactiveCache.getUser(this.userId);
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Meteor.Error('board-not-found');
}
const isBoardAdmin = board.hasAdmin(this.userId);
const isInstanceAdmin = user && user.isAdmin;
if (!isBoardAdmin && !isInstanceAdmin) {
throw new Meteor.Error('not-authorized', 'You must be a board admin or instance admin to perform this action.');
}
return comprehensiveBoardMigration.executeMigration(boardId);
},
'comprehensiveBoardMigration.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return comprehensiveBoardMigration.needsMigration(boardId);
},
'comprehensiveBoardMigration.detectIssues'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return comprehensiveBoardMigration.detectMigrationIssues(boardId);
},
@ -762,12 +762,12 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only instance admins can perform this action.');
}
return comprehensiveBoardMigration.fixAvatarUrls();
},
@ -775,12 +775,12 @@ Meteor.methods({
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
const user = ReactiveCache.getUser(this.userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only instance admins can perform this action.');
}
return comprehensiveBoardMigration.fixAttachmentUrls();
}
});

View file

@ -1,6 +1,6 @@
/**
* Delete Duplicate Empty Lists Migration
*
*
* Safely deletes empty duplicate lists from a board:
* 1. First converts any shared lists to per-swimlane lists
* 2. Only deletes per-swimlane lists that:
@ -42,9 +42,9 @@ class DeleteDuplicateEmptyListsMigration {
const listCards = cards.filter(card => card.listId === list._id);
if (listCards.length === 0) {
// Check if there's a duplicate list with the same title that has cards
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
l.boardId === boardId
);
@ -107,7 +107,7 @@ class DeleteDuplicateEmptyListsMigration {
const lists = ReactiveCache.getLists({ boardId });
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
const cards = ReactiveCache.getCards({ boardId });
let listsConverted = 0;
// Find shared lists (lists without swimlaneId)
@ -137,8 +137,8 @@ class DeleteDuplicateEmptyListsMigration {
if (swimlaneCards.length > 0) {
// Check if per-swimlane list already exists
const existingList = lists.find(l =>
l.title === sharedList.title &&
const existingList = lists.find(l =>
l.title === sharedList.title &&
l.swimlaneId === swimlane._id &&
l._id !== sharedList._id
);
@ -208,7 +208,7 @@ class DeleteDuplicateEmptyListsMigration {
async deleteEmptyPerSwimlaneLists(boardId) {
const lists = ReactiveCache.getLists({ boardId });
const cards = ReactiveCache.getCards({ boardId });
let listsDeleted = 0;
for (const list of lists) {
@ -230,9 +230,9 @@ class DeleteDuplicateEmptyListsMigration {
}
// Safety check 3: There must be another list with the same title on the same board that has cards
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
const duplicateListsWithSameTitle = lists.filter(l =>
l._id !== list._id &&
l.title === list.title &&
l.boardId === boardId
);
@ -321,7 +321,7 @@ const deleteDuplicateEmptyListsMigration = new DeleteDuplicateEmptyListsMigratio
Meteor.methods({
'deleteDuplicateEmptyLists.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -331,7 +331,7 @@ Meteor.methods({
'deleteDuplicateEmptyLists.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -361,7 +361,7 @@ Meteor.methods({
'deleteDuplicateEmptyLists.getStatus'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}

View file

@ -1,11 +1,11 @@
/**
* Migration: Ensure all entities have valid swimlaneId
*
*
* This migration ensures that:
* 1. All cards have a valid swimlaneId
* 2. All lists have a valid swimlaneId (if applicable)
* 3. Orphaned entities (without valid swimlaneId) are moved to a "Rescued Data" swimlane
*
*
* This is similar to the existing rescue migration but specifically for swimlaneId validation
*/
@ -60,7 +60,7 @@ function getOrCreateRescuedSwimlane(boardId) {
});
rescuedSwimlane = Swimlanes.findOne(swimlaneId);
Activities.insert({
userId: 'migration',
type: 'swimlane',
@ -164,7 +164,7 @@ function getOrCreateRescuedSwimlane(boardId) {
let rescuedCount = 0;
const allCards = Cards.find({}).fetch();
allCards.forEach(card => {
if (!card.swimlaneId) return; // Handled by fixCardsWithoutSwimlaneId
@ -173,7 +173,7 @@ function getOrCreateRescuedSwimlane(boardId) {
if (!swimlane) {
// Orphaned card - swimlane doesn't exist
const rescuedSwimlane = getOrCreateRescuedSwimlane(card.boardId);
if (rescuedSwimlane) {
Cards.update(card._id, {
$set: { swimlaneId: rescuedSwimlane._id },
@ -290,7 +290,7 @@ function getOrCreateRescuedSwimlane(boardId) {
);
console.log(`Migration ${MIGRATION_NAME} completed successfully`);
return {
success: true,
cardsFixed: cardResults.fixedCount,
@ -306,7 +306,7 @@ function getOrCreateRescuedSwimlane(boardId) {
// Install validation hooks on startup (always run these for data integrity)
Meteor.startup(() => {
if (!Meteor.isServer) return;
try {
addSwimlaneIdValidationHooks();
console.log('SwimlaneId validation hooks installed');

View file

@ -28,9 +28,9 @@ class FixAllFileUrlsMigration {
if (!board || !board.members) {
return false;
}
const memberIds = board.members.map(m => m.userId);
// Check for problematic avatar URLs for board members
const users = ReactiveCache.getUsers({ _id: { $in: memberIds } });
for (const user of users) {
@ -46,7 +46,7 @@ class FixAllFileUrlsMigration {
const cards = ReactiveCache.getCards({ boardId });
const cardIds = cards.map(c => c._id);
const attachments = ReactiveCache.getAttachments({ cardId: { $in: cardIds } });
for (const attachment of attachments) {
if (attachment.url && this.hasProblematicUrl(attachment.url)) {
return true;
@ -61,17 +61,17 @@ class FixAllFileUrlsMigration {
*/
hasProblematicUrl(url) {
if (!url) return false;
// Check for auth parameters
if (url.includes('auth=false') || url.includes('brokenIsFine=true')) {
return true;
}
// Check for absolute URLs with domains
if (url.startsWith('http://') || url.startsWith('https://')) {
return true;
}
// Check for ROOT_URL dependencies
if (Meteor.isServer && process.env.ROOT_URL) {
try {
@ -83,12 +83,12 @@ class FixAllFileUrlsMigration {
// Ignore URL parsing errors
}
}
// Check for non-universal file URLs
if (url.includes('/cfs/files/') && !isUniversalFileUrl(url, 'attachment') && !isUniversalFileUrl(url, 'avatar')) {
return true;
}
return false;
}
@ -120,7 +120,7 @@ class FixAllFileUrlsMigration {
}
console.log(`Universal file URL migration completed for board ${boardId}. Fixed ${filesFixed} file URLs.`);
return {
success: errors.length === 0,
filesFixed,
@ -137,7 +137,7 @@ class FixAllFileUrlsMigration {
if (!board || !board.members) {
return 0;
}
const memberIds = board.members.map(m => m.userId);
const users = ReactiveCache.getUsers({ _id: { $in: memberIds } });
let avatarsFixed = 0;
@ -145,12 +145,12 @@ class FixAllFileUrlsMigration {
for (const user of users) {
if (user.profile && user.profile.avatarUrl) {
const avatarUrl = user.profile.avatarUrl;
if (this.hasProblematicUrl(avatarUrl)) {
try {
// Extract file ID from URL
const fileId = extractFileIdFromUrl(avatarUrl, 'avatar');
let cleanUrl;
if (fileId) {
// Generate universal URL
@ -159,7 +159,7 @@ class FixAllFileUrlsMigration {
// Clean existing URL
cleanUrl = cleanFileUrl(avatarUrl, 'avatar');
}
if (cleanUrl && cleanUrl !== avatarUrl) {
// Update user's avatar URL
Users.update(user._id, {
@ -168,9 +168,9 @@ class FixAllFileUrlsMigration {
modifiedAt: new Date()
}
});
avatarsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed avatar URL for user ${user.username}: ${avatarUrl} -> ${cleanUrl}`);
}
@ -200,7 +200,7 @@ class FixAllFileUrlsMigration {
try {
const fileId = attachment._id;
const cleanUrl = generateUniversalAttachmentUrl(fileId);
if (cleanUrl && cleanUrl !== attachment.url) {
// Update attachment URL
Attachments.update(attachment._id, {
@ -209,9 +209,9 @@ class FixAllFileUrlsMigration {
modifiedAt: new Date()
}
});
attachmentsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed attachment URL: ${attachment.url} -> ${cleanUrl}`);
}
@ -239,7 +239,7 @@ class FixAllFileUrlsMigration {
try {
const fileId = attachment._id || extractFileIdFromUrl(attachment.url, 'attachment');
const cleanUrl = fileId ? generateUniversalAttachmentUrl(fileId) : cleanFileUrl(attachment.url, 'attachment');
if (cleanUrl && cleanUrl !== attachment.url) {
// Update attachment with fixed URL
Attachments.update(attachment._id, {
@ -248,9 +248,9 @@ class FixAllFileUrlsMigration {
modifiedAt: new Date()
}
});
attachmentsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed attachment URL ${attachment._id}`);
}
@ -272,7 +272,7 @@ export const fixAllFileUrlsMigration = new FixAllFileUrlsMigration();
Meteor.methods({
'fixAllFileUrls.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -296,17 +296,17 @@ Meteor.methods({
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return fixAllFileUrlsMigration.execute(boardId);
},
'fixAllFileUrls.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return fixAllFileUrlsMigration.needsMigration(boardId);
}
});

View file

@ -25,10 +25,10 @@ class FixAvatarUrlsMigration {
if (!board || !board.members) {
return false;
}
const memberIds = board.members.map(m => m.userId);
const users = ReactiveCache.getUsers({ _id: { $in: memberIds } });
for (const user of users) {
if (user.profile && user.profile.avatarUrl) {
const avatarUrl = user.profile.avatarUrl;
@ -37,7 +37,7 @@ class FixAvatarUrlsMigration {
}
}
}
return false;
}
@ -53,7 +53,7 @@ class FixAvatarUrlsMigration {
error: 'Board not found or has no members'
};
}
const memberIds = board.members.map(m => m.userId);
const users = ReactiveCache.getUsers({ _id: { $in: memberIds } });
let avatarsFixed = 0;
@ -65,7 +65,7 @@ class FixAvatarUrlsMigration {
const avatarUrl = user.profile.avatarUrl;
let needsUpdate = false;
let cleanUrl = avatarUrl;
// Check if URL has problematic parameters
if (avatarUrl.includes('auth=false') || avatarUrl.includes('brokenIsFine=true')) {
// Remove problematic parameters
@ -75,13 +75,13 @@ class FixAvatarUrlsMigration {
cleanUrl = cleanUrl.replace(/\?$/g, '');
needsUpdate = true;
}
// Check if URL is using old CollectionFS format
if (avatarUrl.includes('/cfs/files/avatars/')) {
cleanUrl = cleanUrl.replace('/cfs/files/avatars/', '/cdn/storage/avatars/');
needsUpdate = true;
}
// Check if URL is missing the /cdn/storage/avatars/ prefix
if (avatarUrl.includes('avatars/') && !avatarUrl.includes('/cdn/storage/avatars/') && !avatarUrl.includes('/cfs/files/avatars/')) {
// This might be a relative URL, make it absolute
@ -90,14 +90,14 @@ class FixAvatarUrlsMigration {
needsUpdate = true;
}
}
// If we have a file ID, generate a universal URL
const fileId = extractFileIdFromUrl(avatarUrl, 'avatar');
if (fileId && !isUniversalFileUrl(cleanUrl, 'avatar')) {
cleanUrl = generateUniversalAvatarUrl(fileId);
needsUpdate = true;
}
if (needsUpdate) {
// Update user's avatar URL
Users.update(user._id, {
@ -106,9 +106,9 @@ class FixAvatarUrlsMigration {
modifiedAt: new Date()
}
});
avatarsFixed++;
if (process.env.DEBUG === 'true') {
console.log(`Fixed avatar URL for user ${user.username}: ${avatarUrl} -> ${cleanUrl}`);
}
@ -117,7 +117,7 @@ class FixAvatarUrlsMigration {
}
console.log(`Avatar URL fix migration completed for board ${boardId}. Fixed ${avatarsFixed} avatar URLs.`);
return {
success: true,
avatarsFixed,
@ -133,7 +133,7 @@ export const fixAvatarUrlsMigration = new FixAvatarUrlsMigration();
Meteor.methods({
'fixAvatarUrls.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -157,17 +157,17 @@ Meteor.methods({
if (!isBoardAdmin && !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
}
return fixAvatarUrlsMigration.execute(boardId);
},
'fixAvatarUrls.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
return fixAvatarUrlsMigration.needsMigration(boardId);
}
});

View file

@ -1,17 +1,17 @@
/**
* Fix Missing Lists Migration
*
*
* This migration fixes the issue where cards have incorrect listId references
* due to the per-swimlane lists change. It detects cards with mismatched
* listId/swimlaneId and creates the missing lists.
*
*
* Issue: When upgrading from v7.94 to v8.02, cards that were in different
* swimlanes but shared the same list now have wrong listId references.
*
*
* Example:
* - Card1: listId: 'HB93dWNnY5bgYdtxc', swimlaneId: 'sK69SseWkh3tMbJvg'
* - Card2: listId: 'HB93dWNnY5bgYdtxc', swimlaneId: 'XeecF9nZxGph4zcT4'
*
*
* Card2 should have a different listId that corresponds to its swimlane.
*/
@ -44,7 +44,7 @@ class FixMissingListsMigration {
// Check if there are cards with mismatched listId/swimlaneId
const cards = ReactiveCache.getCards({ boardId });
const lists = ReactiveCache.getLists({ boardId });
// Create a map of listId -> swimlaneId for existing lists
const listSwimlaneMap = new Map();
lists.forEach(list => {
@ -77,7 +77,7 @@ class FixMissingListsMigration {
if (process.env.DEBUG === 'true') {
console.log(`Starting fix missing lists migration for board ${boardId}`);
}
const board = ReactiveCache.getBoard(boardId);
if (!board) {
throw new Error(`Board ${boardId} not found`);
@ -90,7 +90,7 @@ class FixMissingListsMigration {
// Create maps for efficient lookup
const listSwimlaneMap = new Map();
const swimlaneListsMap = new Map();
lists.forEach(list => {
listSwimlaneMap.set(list._id, list.swimlaneId || '');
if (!swimlaneListsMap.has(list.swimlaneId || '')) {
@ -142,7 +142,7 @@ class FixMissingListsMigration {
// Check if we already have a list with the same title in this swimlane
let targetList = existingLists.find(list => list.title === originalList.title);
if (!targetList) {
// Create a new list for this swimlane
const newListData = {
@ -168,7 +168,7 @@ class FixMissingListsMigration {
const newListId = Lists.insert(newListData);
targetList = { _id: newListId, ...newListData };
createdLists++;
if (process.env.DEBUG === 'true') {
console.log(`Created new list "${originalList.title}" for swimlane ${swimlaneId}`);
}
@ -198,7 +198,7 @@ class FixMissingListsMigration {
if (process.env.DEBUG === 'true') {
console.log(`Fix missing lists migration completed for board ${boardId}: created ${createdLists} lists, updated ${updatedCards} cards`);
}
return {
success: true,
createdLists,
@ -222,7 +222,7 @@ class FixMissingListsMigration {
}
if (board.fixMissingListsCompleted) {
return {
return {
status: 'completed',
completedAt: board.fixMissingListsCompletedAt
};
@ -247,31 +247,31 @@ export const fixMissingListsMigration = new FixMissingListsMigration();
Meteor.methods({
'fixMissingListsMigration.check'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return fixMissingListsMigration.getMigrationStatus(boardId);
},
'fixMissingListsMigration.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return fixMissingListsMigration.executeMigration(boardId);
},
'fixMissingListsMigration.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized');
}
return fixMissingListsMigration.needsMigration(boardId);
}
});

View file

@ -1,8 +1,8 @@
/**
* Restore All Archived Migration
*
*
* Restores all archived swimlanes, lists, and cards.
* If any restored items are missing swimlaneId, listId, or cardId,
* If any restored items are missing swimlaneId, listId, or cardId,
* creates/assigns proper IDs to make them visible.
*/
@ -90,7 +90,7 @@ class RestoreAllArchivedMigration {
if (!list.swimlaneId) {
// Try to find a suitable swimlane or use default
let targetSwimlane = activeSwimlanes.find(s => !s.archived);
if (!targetSwimlane) {
// No active swimlane found, create default
const swimlaneId = Swimlanes.insert({
@ -139,11 +139,11 @@ class RestoreAllArchivedMigration {
if (!card.listId) {
// Find or create a default list
let targetList = allLists.find(l => !l.archived);
if (!targetList) {
// No active list found, create one
const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
const listId = Lists.insert({
title: TAPi18n.__('default'),
boardId: boardId,
@ -224,7 +224,7 @@ const restoreAllArchivedMigration = new RestoreAllArchivedMigration();
Meteor.methods({
'restoreAllArchived.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -234,7 +234,7 @@ Meteor.methods({
'restoreAllArchived.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}

View file

@ -1,6 +1,6 @@
/**
* Restore Lost Cards Migration
*
*
* Finds and restores cards and lists that have missing swimlaneId, listId, or are orphaned.
* Creates a "Lost Cards" swimlane and restores visibility of lost items.
* Only processes non-archived items.
@ -217,7 +217,7 @@ const restoreLostCardsMigration = new RestoreLostCardsMigration();
Meteor.methods({
'restoreLostCards.needsMigration'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}
@ -227,7 +227,7 @@ Meteor.methods({
'restoreLostCards.execute'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in');
}

View file

@ -5,7 +5,7 @@ import { meteorMongoIntegration } from '/models/lib/meteorMongoIntegration';
/**
* MongoDB Driver Startup
*
*
* This module initializes the MongoDB driver system on server startup,
* providing automatic version detection and driver selection for
* MongoDB versions 3.0 through 8.0.
@ -14,7 +14,7 @@ import { meteorMongoIntegration } from '/models/lib/meteorMongoIntegration';
// Initialize MongoDB driver system on server startup
Meteor.startup(async function() {
// MongoDB Driver System Startup (status available in Admin Panel)
try {
// Check if MONGO_URL is available
const mongoUrl = process.env.MONGO_URL;
@ -31,7 +31,7 @@ Meteor.startup(async function() {
// Test the connection
const testResult = await meteorMongoIntegration.testConnection();
if (testResult.success) {
// MongoDB connection test successful
// Driver and version information available in Admin Panel
@ -51,7 +51,7 @@ Meteor.startup(async function() {
} catch (error) {
console.error('Error during MongoDB driver system startup:', error.message);
console.error('Stack trace:', error.stack);
// Don't fail the entire startup, just log the error
console.log('Continuing with default MongoDB connection...');
}
@ -65,7 +65,7 @@ if (Meteor.isServer) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return {
connectionStats: mongodbConnectionManager.getConnectionStats(),
driverStats: mongodbDriverManager.getConnectionStats(),
@ -77,7 +77,7 @@ if (Meteor.isServer) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return await meteorMongoIntegration.testConnection();
},
@ -85,7 +85,7 @@ if (Meteor.isServer) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
meteorMongoIntegration.reset();
return { success: true, message: 'MongoDB driver system reset' };
},
@ -94,7 +94,7 @@ if (Meteor.isServer) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return {
supportedVersions: mongodbDriverManager.getSupportedVersions(),
compatibility: mongodbDriverManager.getSupportedVersions().map(version => {
@ -120,11 +120,11 @@ if (Meteor.isServer) {
}
const self = this;
// Send initial data
const stats = meteorMongoIntegration.getStats();
self.added('mongodbDriverMonitor', 'stats', stats);
// Update every 30 seconds
const interval = setInterval(() => {
const updatedStats = meteorMongoIntegration.getStats();

View file

@ -31,7 +31,7 @@ Notifications = {
notify: (user, title, description, params) => {
// Skip if user is invalid
if (!user || !user._id) return;
for (const k in notifyServices) {
const notifyImpl = notifyServices[k];
if (notifyImpl && typeof notifyImpl === 'function')

View file

@ -0,0 +1,43 @@
import { AttachmentMigrationStatus } from '../attachmentMigrationStatus';
// Publish attachment migration status for boards user has access to
Meteor.publish('attachmentMigrationStatus', function(boardId) {
if (!this.userId) {
return this.ready();
}
check(boardId, String);
const board = Boards.findOne(boardId);
if (!board || !board.isVisibleBy({ _id: this.userId })) {
return this.ready();
}
// Publish migration status for this board
return AttachmentMigrationStatus.find({ boardId });
});
// Publish all attachment migration statuses for user's boards
Meteor.publish('attachmentMigrationStatuses', function() {
if (!this.userId) {
return this.ready();
}
const user = Users.findOne(this.userId);
if (!user) {
return this.ready();
}
// Get all boards user has access to
const boards = Boards.find({
$or: [
{ 'members.userId': this.userId },
{ isPublic: true }
]
}, { fields: { _id: 1 } }).fetch();
const boardIds = boards.map(b => b._id);
// Publish migration status for all user's boards
return AttachmentMigrationStatus.find({ boardId: { $in: boardIds } });
});

View file

@ -2,25 +2,25 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { publishComposite } from 'meteor/reywood:publish-composite';
import escapeForRegex from 'escape-string-regexp';
import Users from '../../models/users';
import {
formatDateTime,
formatDate,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
import {
formatDateTime,
formatDate,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
import Boards from '../../models/boards';
import Lists from '../../models/lists';
@ -76,19 +76,19 @@ import Team from "../../models/team";
Meteor.publish('card', cardId => {
check(cardId, String);
const userId = Meteor.userId();
const card = ReactiveCache.getCard({ _id: cardId });
if (!card || !card.boardId) {
return [];
}
const board = ReactiveCache.getBoard({ _id: card.boardId });
if (!board || !board.isVisibleBy(userId)) {
return [];
}
// If user has assigned-only permissions, check if they're assigned to this card
if (userId && board.members) {
const member = _.findWhere(board.members, { userId: userId, isActive: true });
@ -99,7 +99,7 @@ Meteor.publish('card', cardId => {
}
}
}
const ret = ReactiveCache.getCards(
{ _id: cardId },
{},
@ -177,7 +177,7 @@ Meteor.publish('myCards', function(sessionId) {
// Optimized due cards publication for better performance
Meteor.publish('dueCards', function(allUsers = false) {
check(allUsers, Boolean);
const userId = this.userId;
if (!userId) {
return this.ready();
@ -198,7 +198,7 @@ Meteor.publish('dueCards', function(allUsers = false) {
if (process.env.DEBUG === 'true') {
console.log('dueCards userBoards:', userBoards);
console.log('dueCards userBoards count:', userBoards.length);
// Also check if there are any cards with due dates in the system at all
const allCardsWithDueDates = Cards.find({
type: 'cardType-card',
@ -255,7 +255,7 @@ Meteor.publish('dueCards', function(allUsers = false) {
}
const result = Cards.find(selector, options);
if (process.env.DEBUG === 'true') {
const count = result.count();
console.log('dueCards publication: returning', count, 'cards');
@ -295,7 +295,7 @@ Meteor.publish('sessionData', function(sessionId) {
if (process.env.DEBUG === 'true') {
console.log('sessionData publication called with:', { sessionId, userId });
}
const cursor = SessionData.find({ userId, sessionId });
if (process.env.DEBUG === 'true') {
console.log('sessionData publication returning cursor with count:', cursor.count());
@ -903,7 +903,7 @@ function findCards(sessionId, query) {
if (process.env.DEBUG === 'true') {
console.log('findCards - upsertResult:', upsertResult);
}
// Check if the session data was actually stored
const storedSessionData = SessionData.findOne({ userId, sessionId });
if (process.env.DEBUG === 'true') {
@ -968,7 +968,7 @@ function findCards(sessionId, query) {
console.log('findCards - session data count (after delay):', sessionDataCursor.count());
}
}, 100);
const sessionDataCursor = SessionData.find({ userId, sessionId });
if (process.env.DEBUG === 'true') {
console.log('findCards - publishing session data cursor:', sessionDataCursor);

View file

@ -0,0 +1,16 @@
import { CronJobStatus } from '/server/cronJobStorage';
// Publish cron jobs status for admin users only
Meteor.publish('cronJobs', function() {
if (!this.userId) {
return this.ready();
}
const user = Users.findOne(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
// Publish all cron job status documents
return CronJobStatus.find({});
});

View file

@ -0,0 +1,16 @@
import { CronJobStatus } from '../cronJobStorage';
// Publish migration status for admin users only
Meteor.publish('cronMigrationStatus', function() {
if (!this.userId) {
return this.ready();
}
const user = Users.findOne(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
// Publish all cron job status documents
return CronJobStatus.find({});
});

View file

@ -0,0 +1,29 @@
// Publish custom UI configuration
Meteor.publish('customUI', function() {
// Published to all users (public configuration)
return Settings.find({}, {
fields: {
customLoginLogoImageUrl: 1,
customLoginLogoLinkUrl: 1,
customHelpLinkUrl: 1,
textBelowCustomLoginLogo: 1,
customTopLeftCornerLogoImageUrl: 1,
customTopLeftCornerLogoLinkUrl: 1,
customTopLeftCornerLogoHeight: 1,
customHTMLafterBodyStart: 1,
customHTMLbeforeBodyEnd: 1,
}
});
});
// Publish Matomo configuration
Meteor.publish('matomoConfig', function() {
// Published to all users (public configuration)
return Settings.find({}, {
fields: {
matomoEnabled: 1,
matomoURL: 1,
matomoSiteId: 1,
}
});
});

View file

@ -0,0 +1,22 @@
import { CronJobStatus } from '/server/cronJobStorage';
// Publish detailed migration progress data for admin users
Meteor.publish('migrationProgress', function() {
if (!this.userId) {
return this.ready();
}
const user = Users.findOne(this.userId);
if (!user || !user.isAdmin) {
return this.ready();
}
// Publish detailed migration progress documents
// This includes current running job details, estimated time, etc.
return CronJobStatus.find({
$or: [
{ jobType: 'migration' },
{ jobId: 'migration' }
]
});
});

View file

@ -62,10 +62,10 @@ if (Meteor.isServer) {
try {
const userId = authenticateApiRequest(req);
let body = '';
let bodyComplete = false;
req.on('data', chunk => {
body += chunk.toString();
// Prevent excessive payload
@ -79,7 +79,7 @@ if (Meteor.isServer) {
if (bodyComplete) return; // Already processed
bodyComplete = true;
clearTimeout(timeout);
try {
const data = JSON.parse(body);
const { boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend } = data;
@ -192,7 +192,7 @@ if (Meteor.isServer) {
sendErrorResponse(res, 500, error.message);
}
});
req.on('error', (error) => {
clearTimeout(timeout);
if (!res.headersSent) {
@ -245,7 +245,7 @@ if (Meteor.isServer) {
readStream.on('end', () => {
const fileBuffer = Buffer.concat(chunks);
const base64Data = fileBuffer.toString('base64');
sendJsonResponse(res, 200, {
success: true,
attachmentId: attachmentId,
@ -308,7 +308,7 @@ if (Meteor.isServer) {
}
const attachments = ReactiveCache.getAttachments(query);
const attachmentList = attachments.map(attachment => {
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
return {
@ -350,10 +350,10 @@ if (Meteor.isServer) {
try {
const userId = authenticateApiRequest(req);
let body = '';
let bodyComplete = false;
req.on('data', chunk => {
body += chunk.toString();
if (body.length > 10 * 1024 * 1024) { // 10MB limit for metadata
@ -366,7 +366,7 @@ if (Meteor.isServer) {
if (bodyComplete) return;
bodyComplete = true;
clearTimeout(timeout);
try {
const data = JSON.parse(body);
const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data;
@ -478,7 +478,7 @@ if (Meteor.isServer) {
sendErrorResponse(res, 500, error.message);
}
});
req.on('error', (error) => {
clearTimeout(timeout);
if (!res.headersSent) {
@ -506,10 +506,10 @@ if (Meteor.isServer) {
try {
const userId = authenticateApiRequest(req);
let body = '';
let bodyComplete = false;
req.on('data', chunk => {
body += chunk.toString();
if (body.length > 10 * 1024 * 1024) {
@ -522,7 +522,7 @@ if (Meteor.isServer) {
if (bodyComplete) return;
bodyComplete = true;
clearTimeout(timeout);
try {
const data = JSON.parse(body);
const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data;
@ -595,7 +595,7 @@ if (Meteor.isServer) {
sendErrorResponse(res, 500, error.message);
}
});
req.on('error', (error) => {
clearTimeout(timeout);
if (!res.headersSent) {
@ -668,7 +668,7 @@ if (Meteor.isServer) {
}
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
sendJsonResponse(res, 200, {
success: true,
attachmentId: attachment._id,

View file

@ -20,7 +20,7 @@ if (Meteor.isServer) {
try {
const fileName = req.params[0];
if (!fileName) {
res.writeHead(400);
res.end('Invalid avatar file name');
@ -29,7 +29,7 @@ if (Meteor.isServer) {
// Extract file ID from filename (format: fileId-original-filename)
const fileId = fileName.split('-original-')[0];
if (!fileId) {
res.writeHead(400);
res.end('Invalid avatar file format');
@ -68,7 +68,7 @@ if (Meteor.isServer) {
res.setHeader('Content-Length', avatar.size || 0);
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
res.setHeader('ETag', `"${avatar._id}"`);
// Handle conditional requests
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === `"${avatar._id}"`) {
@ -106,12 +106,12 @@ if (Meteor.isServer) {
try {
const fileName = req.params[0];
// Redirect to new avatar URL format
const newUrl = `/cdn/storage/avatars/${fileName}`;
res.writeHead(301, { 'Location': newUrl });
res.end();
} catch (error) {
console.error('Legacy avatar redirect error:', error);
res.writeHead(500);

View file

@ -33,7 +33,7 @@ function sanitizeFilenameForHeader(filename) {
// For non-ASCII filenames, provide a fallback and RFC 5987 encoded version
const fallback = sanitized.replace(/[^\x20-\x7E]/g, '_').slice(0, 100) || 'download';
const encoded = encodeURIComponent(sanitized);
// Return special marker format that will be handled by buildContentDispositionHeader
// Format: "fallback|RFC5987:encoded"
return `${fallback}|RFC5987:${encoded}`;

View file

@ -26,7 +26,7 @@ if (Meteor.isServer) {
const nameLower = (fileObj.name || '').toLowerCase();
const typeLower = (fileObj.type || '').toLowerCase();
const isPdfByExt = nameLower.endsWith('.pdf');
// Define dangerous types that must never be served inline
const dangerousTypes = new Set([
'text/html',
@ -37,7 +37,7 @@ if (Meteor.isServer) {
'application/javascript',
'text/javascript'
]);
// Define safe types that can be served inline for viewing
const safeInlineTypes = new Set([
'application/pdf',
@ -59,7 +59,7 @@ if (Meteor.isServer) {
'text/plain',
'application/json'
]);
const isSvg = nameLower.endsWith('.svg') || typeLower === 'image/svg+xml';
const isDangerous = dangerousTypes.has(typeLower) || isSvg;
// Consider PDF safe inline by extension if type is missing/mis-set
@ -342,7 +342,7 @@ if (Meteor.isServer) {
// For non-ASCII filenames, provide a fallback and RFC 5987 encoded version
const fallback = sanitized.replace(/[^\x20-\x7E]/g, '_').slice(0, 100) || 'download';
const encoded = encodeURIComponent(sanitized);
// Return special marker format that will be handled by buildContentDispositionHeader
// Format: "fallback|RFC5987:encoded"
return `${fallback}|RFC5987:${encoded}`;
@ -396,7 +396,7 @@ if (Meteor.isServer) {
try {
const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments');
if (!fileId) {
res.writeHead(400);
res.end('Invalid attachment file ID');
@ -483,7 +483,7 @@ if (Meteor.isServer) {
try {
const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars');
if (!fileId) {
res.writeHead(400);
res.end('Invalid avatar file ID');
@ -548,7 +548,7 @@ if (Meteor.isServer) {
try {
const attachmentId = extractFirstIdFromUrl(req, '/cfs/files/attachments');
if (!attachmentId) {
res.writeHead(400);
res.end('Invalid attachment ID');
@ -624,7 +624,7 @@ if (Meteor.isServer) {
try {
const avatarId = extractFirstIdFromUrl(req, '/cfs/files/avatars');
if (!avatarId) {
res.writeHead(400);
res.end('Invalid avatar ID');
@ -633,7 +633,7 @@ if (Meteor.isServer) {
// Try to get avatar from database (new structure first)
let avatar = ReactiveCache.getAvatar(avatarId);
// If not found in new structure, try to handle legacy format
if (!avatar) {
// For legacy avatars, we might need to handle different ID formats