From 30620d0ca4b750582429cba18d8c676b2191e57a Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Sat, 25 Oct 2025 21:09:07 +0300 Subject: [PATCH] Some migrations and mobile fixes. Thanks to xet7 ! --- client/components/boards/boardBody.css | 46 +- client/components/boards/boardBody.js | 202 +++-- client/components/boards/boardHeader.css | 126 +-- client/components/lists/list.css | 66 +- client/components/migrationProgress.css | 467 +++++------ client/components/migrationProgress.jade | 100 +-- client/components/migrationProgress.js | 234 +++++- client/components/swimlanes/swimlanes.css | 2 +- client/components/users/userAvatar.jade | 2 +- client/components/users/userAvatar.js | 4 +- models/attachments.js | 12 + models/avatars.js | 16 +- models/lib/universalUrlGenerator.js | 194 +++++ server/00checkStartup.js | 6 + server/cors.js | 15 + .../migrations/comprehensiveBoardMigration.js | 767 ++++++++++++++++++ server/migrations/fixAllFileUrls.js | 277 +++++++ server/migrations/fixAvatarUrls.js | 128 +++ server/routes/avatarServer.js | 123 +++ server/routes/universalFileServer.js | 393 +++++++++ 20 files changed, 2638 insertions(+), 542 deletions(-) create mode 100644 models/lib/universalUrlGenerator.js create mode 100644 server/migrations/comprehensiveBoardMigration.js create mode 100644 server/migrations/fixAllFileUrls.js create mode 100644 server/migrations/fixAvatarUrls.js create mode 100644 server/routes/avatarServer.js create mode 100644 server/routes/universalFileServer.js diff --git a/client/components/boards/boardBody.css b/client/components/boards/boardBody.css index 05fa8fc58..f65cbaffc 100644 --- a/client/components/boards/boardBody.css +++ b/client/components/boards/boardBody.css @@ -269,57 +269,71 @@ } /* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */ .board-wrapper.mobile-view { - width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; left: 0 !important; right: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; } .board-wrapper.mobile-view .board-canvas { - width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; left: 0 !important; right: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; } .board-wrapper.mobile-view .board-canvas.mobile-view .swimlane { border-bottom: 1px solid #ccc; - display: flex; + display: block !important; flex-direction: column; margin: 0; padding: 0; - overflow-x: hidden; + overflow-x: hidden !important; overflow-y: auto; - width: 100%; - min-width: 100%; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; } @media screen and (max-width: 800px), screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) { .board-wrapper { - width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; left: 0 !important; right: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; } .board-wrapper .board-canvas { - width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; left: 0 !important; right: 0 !important; + overflow-x: hidden !important; + overflow-y: auto !important; } .board-wrapper .board-canvas .swimlane { border-bottom: 1px solid #ccc; - display: flex; + display: block !important; flex-direction: column; margin: 0; padding: 0; - overflow-x: hidden; + overflow-x: hidden !important; overflow-y: auto; - width: 100%; - min-width: 100%; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; } } .calendar-event-green { diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index a9b04cddb..e8e83a134 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -4,6 +4,7 @@ import dragscroll from '@wekanteam/dragscroll'; import { boardConverter } from '/client/lib/boardConverter'; import { migrationManager } from '/client/lib/migrationManager'; import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager'; +import { migrationProgressManager } from '/client/components/migrationProgress'; import Swimlanes from '/models/swimlanes'; import Lists from '/models/lists'; @@ -98,61 +99,25 @@ BlazeComponent.extendComponent({ return; } - // Check if board needs migration based on migration version - // DISABLED: Migration check and execution - // const needsMigration = !board.migrationVersion || board.migrationVersion < 1; + // Check if board needs comprehensive migration + const needsMigration = await this.checkComprehensiveMigration(boardId); - // if (needsMigration) { - // // Start background migration for old boards - // this.isMigrating.set(true); - // await this.startBackgroundMigration(boardId); - // this.isMigrating.set(false); - // } + if (needsMigration) { + // Start comprehensive migration + this.isMigrating.set(true); + const success = await this.executeComprehensiveMigration(boardId); + this.isMigrating.set(false); + + if (success) { + this.isBoardReady.set(true); + } else { + console.error('Comprehensive migration failed, setting ready to true anyway'); + this.isBoardReady.set(true); // Still show board even if migration failed + } + } else { + this.isBoardReady.set(true); + } - // Check if board needs conversion (for old structure) - // DISABLED: Board conversion logic - // if (boardConverter.isBoardConverted(boardId)) { - // if (process.env.DEBUG === 'true') { - // console.log(`Board ${boardId} has already been converted, skipping conversion`); - // } - // this.isBoardReady.set(true); - // } else { - // const needsConversion = boardConverter.needsConversion(boardId); - // - // if (needsConversion) { - // this.isConverting.set(true); - // const success = await boardConverter.convertBoard(boardId); - // this.isConverting.set(false); - // - // if (success) { - // this.isBoardReady.set(true); - // } else { - // console.error('Board conversion failed, setting ready to true anyway'); - // this.isBoardReady.set(true); // Still show board even if conversion failed - // } - // } else { - // this.isBoardReady.set(true); - // } - // } - - // Set board ready immediately since conversions are disabled - this.isBoardReady.set(true); - - // Convert shared lists to per-swimlane lists if needed - // DISABLED: Shared lists conversion - // await this.convertSharedListsToPerSwimlane(boardId); - - // Fix missing lists migration (for cards with wrong listId references) - // DISABLED: Missing lists fix - // await this.fixMissingLists(boardId); - - // Fix duplicate lists created by WeKan 8.10 - // DISABLED: Duplicate lists fix - // await this.fixDuplicateLists(boardId); - - // Start attachment migration in background if needed - // DISABLED: Attachment migration - // this.startAttachmentMigrationIfNeeded(boardId); } catch (error) { console.error('Error during board conversion check:', error); this.isConverting.set(false); @@ -161,6 +126,137 @@ BlazeComponent.extendComponent({ } }, + /** + * Check if board needs comprehensive migration + */ + async checkComprehensiveMigration(boardId) { + try { + return new Promise((resolve, reject) => { + Meteor.call('comprehensiveBoardMigration.needsMigration', boardId, (error, result) => { + if (error) { + console.error('Error checking comprehensive migration:', error); + reject(error); + } else { + resolve(result); + } + }); + }); + } catch (error) { + console.error('Error checking comprehensive migration:', error); + return false; + } + }, + + /** + * Execute comprehensive migration for a board + */ + async executeComprehensiveMigration(boardId) { + try { + // Start progress tracking + migrationProgressManager.startMigration(); + + // Simulate progress updates since we can't easily pass callbacks through Meteor methods + const progressSteps = [ + { step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 1000 }, + { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 }, + { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 }, + { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 }, + { step: 'cleanup_empty_lists', name: 'Cleanup Empty Lists', duration: 1000 }, + { step: 'validate_migration', name: 'Validate Migration', duration: 1000 }, + { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 }, + { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 } + ]; + + // Start the actual migration + const migrationPromise = new Promise((resolve, reject) => { + Meteor.call('comprehensiveBoardMigration.execute', boardId, (error, result) => { + if (error) { + console.error('Error executing comprehensive migration:', error); + migrationProgressManager.failMigration(error); + reject(error); + } else { + if (process.env.DEBUG === 'true') { + console.log('Comprehensive migration completed for board:', boardId, result); + } + resolve(result.success); + } + }); + }); + + // Simulate progress updates + const progressPromise = this.simulateMigrationProgress(progressSteps); + + // Wait for both to complete + const [migrationResult] = await Promise.all([migrationPromise, progressPromise]); + + migrationProgressManager.completeMigration(); + return migrationResult; + + } catch (error) { + console.error('Error executing comprehensive migration:', error); + migrationProgressManager.failMigration(error); + return false; + } + }, + + /** + * Simulate migration progress updates + */ + async simulateMigrationProgress(progressSteps) { + const totalSteps = progressSteps.length; + + for (let i = 0; i < progressSteps.length; i++) { + const step = progressSteps[i]; + const stepProgress = Math.round(((i + 1) / totalSteps) * 100); + + // Update progress for this step + migrationProgressManager.updateProgress({ + overallProgress: stepProgress, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: 0, + stepStatus: `Starting ${step.name}...`, + stepDetails: null, + boardId: Session.get('currentBoard') + }); + + // Simulate step progress + const stepDuration = step.duration; + const updateInterval = 100; // Update every 100ms + const totalUpdates = stepDuration / updateInterval; + + for (let j = 0; j < totalUpdates; j++) { + const stepStepProgress = Math.round(((j + 1) / totalUpdates) * 100); + + migrationProgressManager.updateProgress({ + overallProgress: stepProgress, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: stepStepProgress, + stepStatus: `Processing ${step.name}...`, + stepDetails: { progress: `${stepStepProgress}%` }, + boardId: Session.get('currentBoard') + }); + + await new Promise(resolve => setTimeout(resolve, updateInterval)); + } + + // Complete the step + migrationProgressManager.updateProgress({ + overallProgress: stepProgress, + currentStep: i + 1, + totalSteps, + stepName: step.step, + stepProgress: 100, + stepStatus: `${step.name} completed`, + stepDetails: { status: 'completed' }, + boardId: Session.get('currentBoard') + }); + } + }, + async startBackgroundMigration(boardId) { try { // Start background migration using the cron system diff --git a/client/components/boards/boardHeader.css b/client/components/boards/boardHeader.css index f3cb652e7..faf20e2f5 100644 --- a/client/components/boards/boardHeader.css +++ b/client/components/boards/boardHeader.css @@ -505,73 +505,73 @@ flex-wrap: nowrap !important; align-items: stretch !important; justify-content: flex-start !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; overflow-x: hidden !important; overflow-y: auto !important; } -.mobile-mode .swimlane { - display: block !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; - margin: 0 0 2rem 0 !important; - padding: 0 !important; - float: none !important; - clear: both !important; -} + .mobile-mode .swimlane { + display: block !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; + margin: 0 0 2rem 0 !important; + padding: 0 !important; + float: none !important; + clear: both !important; + } -.mobile-mode .swimlane .swimlane-header { - display: block !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; - margin: 0 0 1rem 0 !important; - padding: 1rem !important; - font-size: clamp(18px, 2.5vw, 32px) !important; - font-weight: bold !important; - border-bottom: 2px solid #ccc !important; -} + .mobile-mode .swimlane .swimlane-header { + display: block !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; + margin: 0 0 1rem 0 !important; + padding: 1rem !important; + font-size: clamp(18px, 2.5vw, 32px) !important; + font-weight: bold !important; + border-bottom: 2px solid #ccc !important; + } -.mobile-mode .swimlane .lists { - display: block !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; - margin: 0 !important; - padding: 0 !important; - flex-direction: column !important; - flex-wrap: nowrap !important; - align-items: stretch !important; - justify-content: flex-start !important; -} + .mobile-mode .swimlane .lists { + display: block !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; + margin: 0 !important; + padding: 0 !important; + flex-direction: column !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + justify-content: flex-start !important; + } -.mobile-mode .list { - display: block !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; - margin: 0 0 2rem 0 !important; - padding: 0 !important; - float: none !important; - clear: both !important; - border-left: none !important; - border-right: none !important; - border-top: none !important; - border-bottom: 2px solid #ccc !important; - flex: none !important; - flex-basis: auto !important; - flex-grow: 0 !important; - flex-shrink: 0 !important; - position: static !important; - left: auto !important; - right: auto !important; - top: auto !important; - bottom: auto !important; - transform: none !important; -} + .mobile-mode .list { + display: block !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; + margin: 0 0 2rem 0 !important; + padding: 0 !important; + float: none !important; + clear: both !important; + border-left: none !important; + border-right: none !important; + border-top: none !important; + border-bottom: 2px solid #ccc !important; + flex: none !important; + flex-basis: auto !important; + flex-grow: 0 !important; + flex-shrink: 0 !important; + position: static !important; + left: auto !important; + right: auto !important; + top: auto !important; + bottom: auto !important; + transform: none !important; + } .mobile-mode .list:first-child { margin-left: 0 !important; @@ -667,9 +667,9 @@ flex-wrap: nowrap !important; align-items: stretch !important; justify-content: flex-start !important; - width: 100% !important; - max-width: 100% !important; - min-width: 100% !important; + width: 100vw !important; + max-width: 100vw !important; + min-width: 100vw !important; overflow-x: hidden !important; overflow-y: auto !important; } diff --git a/client/components/lists/list.css b/client/components/lists/list.css index 53426199b..77e78de29 100644 --- a/client/components/lists/list.css +++ b/client/components/lists/list.css @@ -641,17 +641,22 @@ body.list-resizing-active * { .mini-list.mobile-view { flex: 0 0 60px; height: auto; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list.mobile-view { - display: contents; + display: block !important; flex-basis: auto; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; border-left: 0px !important; + margin: 0 !important; + padding: 0 !important; } .list.mobile-view:first-child { margin-left: 0px; @@ -659,9 +664,11 @@ body.list-resizing-active * { .list.mobile-view.ui-sortable-helper { flex: 0 0 60px; height: 60px; - width: 100%; + width: 100vw; + max-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list.mobile-view.ui-sortable-helper .list-header.ui-sortable-handle { cursor: grabbing; @@ -669,14 +676,17 @@ body.list-resizing-active * { .list.mobile-view.placeholder { flex: 0 0 60px; height: 60px; - width: 100%; + width: 100vw; + max-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list.mobile-view .list-body { padding: 15px 19px; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; } .list.mobile-view .list-header { /*Updated padding values for mobile devices, this should fix text grouping issue*/ @@ -685,8 +695,9 @@ body.list-resizing-active * { min-height: 30px; margin-top: 10px; align-items: center; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; /* Force grid layout for iPhone */ display: grid !important; grid-template-columns: 30px 1fr auto auto !important; @@ -767,17 +778,22 @@ body.list-resizing-active * { .mini-list { flex: 0 0 60px; height: auto; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list { - display: contents; + display: block !important; flex-basis: auto; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; border-left: 0px !important; + margin: 0 !important; + padding: 0 !important; } .list:first-child { margin-left: 0px; @@ -785,9 +801,11 @@ body.list-resizing-active * { .list.ui-sortable-helper { flex: 0 0 60px; height: 60px; - width: 100%; + width: 100vw; + max-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list.ui-sortable-helper .list-header.ui-sortable-handle { cursor: grabbing; @@ -795,14 +813,17 @@ body.list-resizing-active * { .list.placeholder { flex: 0 0 60px; height: 60px; - width: 100%; + width: 100vw; + max-width: 100vw; border-left: 0px !important; border-bottom: 1px solid #ccc; + display: block !important; } .list-body { padding: 15px 19px; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; } .list-header { /*Updated padding values for mobile devices, this should fix text grouping issue*/ @@ -811,8 +832,9 @@ body.list-resizing-active * { min-height: 30px; margin-top: 10px; align-items: center; - width: 100%; - min-width: 100%; + width: 100vw; + max-width: 100vw; + min-width: 100vw; } .list-header .list-header-left-icon { padding: 7px; diff --git a/client/components/migrationProgress.css b/client/components/migrationProgress.css index d44f4eda8..f3b9a45d4 100644 --- a/client/components/migrationProgress.css +++ b/client/components/migrationProgress.css @@ -1,38 +1,33 @@ /* Migration Progress Styles */ -.migration-overlay { +.migration-progress-overlay { position: fixed; top: 0; left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.8); - z-index: 10000; - display: none; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 9999; + display: flex; align-items: center; justify-content: center; - overflow-y: auto; + backdrop-filter: blur(2px); } -.migration-overlay.active { - display: flex; -} - -.migration-modal { +.migration-progress-modal { background: white; - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); - max-width: 800px; - width: 95%; - max-height: 90vh; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + max-width: 500px; + width: 90%; + max-height: 80vh; overflow: hidden; - animation: slideInScale 0.4s ease-out; - margin: 20px; + animation: migrationModalSlideIn 0.3s ease-out; } -@keyframes slideInScale { +@keyframes migrationModalSlideIn { from { opacity: 0; - transform: translateY(-30px) scale(0.95); + transform: translateY(-20px) scale(0.95); } to { opacity: 1; @@ -40,333 +35,235 @@ } } -.migration-header { - padding: 24px 32px 20px; - border-bottom: 2px solid #e0e0e0; - text-align: center; +.migration-progress-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; } -.migration-header h3 { - margin: 0 0 8px 0; - font-size: 24px; +.migration-progress-title { + margin: 0; + font-size: 18px; font-weight: 600; } -.migration-header h3 i { - margin-right: 12px; - color: #FFD700; -} - -.migration-header p { - margin: 0; +.migration-progress-close { + cursor: pointer; font-size: 16px; - opacity: 0.9; + opacity: 0.8; + transition: opacity 0.2s ease; } -.migration-content { - padding: 24px 32px; - max-height: 60vh; - overflow-y: auto; +.migration-progress-close:hover { + opacity: 1; } -.migration-overview { - margin-bottom: 32px; - padding: 20px; - background: #f8f9fa; - border-radius: 8px; - border-left: 4px solid #667eea; +.migration-progress-content { + padding: 30px; } -.overall-progress { - margin-bottom: 20px; +.migration-progress-overall { + margin-bottom: 25px; } -.progress-bar { - width: 100%; - height: 12px; - background-color: #e0e0e0; - border-radius: 6px; - overflow: hidden; +.migration-progress-overall-label { + font-weight: 600; + color: #333; margin-bottom: 8px; - position: relative; + font-size: 14px; } -.progress-fill { +.migration-progress-overall-bar { + background: #e9ecef; + border-radius: 10px; + height: 12px; + overflow: hidden; + margin-bottom: 5px; +} + +.migration-progress-overall-fill { + background: linear-gradient(90deg, #28a745, #20c997); height: 100%; - background: linear-gradient(90deg, #667eea, #764ba2); - border-radius: 6px; + border-radius: 10px; transition: width 0.3s ease; position: relative; } -.progress-fill::after { +.migration-progress-overall-fill::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.4), - transparent - ); - animation: shimmer 2s infinite; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); + animation: migrationProgressShimmer 2s infinite; } -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } +@keyframes migrationProgressShimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } } -.progress-text { - text-align: center; - font-weight: 700; - color: #667eea; - font-size: 18px; -} - -.progress-label { - text-align: center; - color: #666; - font-size: 14px; - margin-top: 4px; -} - -.current-step { - text-align: center; - color: #333; - font-size: 16px; - font-weight: 500; - margin-bottom: 16px; -} - -.current-step i { - margin-right: 8px; - color: #667eea; -} - -.estimated-time { - text-align: center; - color: #666; - font-size: 14px; - background-color: #fff3cd; - padding: 8px 12px; - border-radius: 4px; - border: 1px solid #ffeaa7; -} - -.estimated-time i { - margin-right: 6px; - color: #f39c12; -} - -.migration-steps { - margin-bottom: 24px; -} - -.migration-steps h4 { - margin: 0 0 16px 0; - color: #333; - font-size: 18px; - font-weight: 600; -} - -.steps-list { - max-height: 300px; - overflow-y: auto; - border: 1px solid #e0e0e0; - border-radius: 8px; -} - -.migration-step { - padding: 16px 20px; - border-bottom: 1px solid #f0f0f0; - transition: all 0.3s ease; -} - -.migration-step:last-child { - border-bottom: none; -} - -.migration-step.completed { - background-color: #d4edda; - border-left: 4px solid #28a745; -} - -.migration-step.current { - background-color: #cce7ff; - border-left: 4px solid #667eea; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0% { - box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4); - } - 70% { - box-shadow: 0 0 0 10px rgba(102, 126, 234, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(102, 126, 234, 0); - } -} - -.step-header { - display: flex; - align-items: center; - margin-bottom: 8px; -} - -.step-icon { - margin-right: 12px; - font-size: 18px; - width: 24px; - text-align: center; -} - -.step-icon i.fa-check-circle { - color: #28a745; -} - -.step-icon i.fa-cog.fa-spin { - color: #667eea; -} - -.step-icon i.fa-circle-o { - color: #ccc; -} - -.step-info { - flex: 1; -} - -.step-name { - font-weight: 600; - color: #333; - font-size: 14px; - margin-bottom: 2px; -} - -.step-description { - color: #666; - font-size: 12px; - line-height: 1.3; -} - -.step-progress { +.migration-progress-overall-percentage { text-align: right; - min-width: 40px; -} - -.step-progress .progress-text { font-size: 12px; + color: #666; font-weight: 600; } -.step-progress-bar { - width: 100%; - height: 4px; - background-color: #e0e0e0; - border-radius: 2px; - overflow: hidden; - margin-top: 8px; +.migration-progress-current-step { + margin-bottom: 25px; } -.step-progress-bar .progress-fill { +.migration-progress-step-label { + font-weight: 600; + color: #333; + margin-bottom: 8px; + font-size: 14px; +} + +.migration-progress-step-bar { + background: #e9ecef; + border-radius: 8px; + height: 8px; + overflow: hidden; + margin-bottom: 5px; +} + +.migration-progress-step-fill { + background: linear-gradient(90deg, #007bff, #0056b3); height: 100%; - background: linear-gradient(90deg, #667eea, #764ba2); - border-radius: 2px; + border-radius: 8px; transition: width 0.3s ease; } -.migration-status { - text-align: center; - color: #333; - font-size: 16px; - background-color: #e3f2fd; - padding: 12px 16px; +.migration-progress-step-percentage { + text-align: right; + font-size: 12px; + color: #666; + font-weight: 600; +} + +.migration-progress-status { + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; border-radius: 6px; - border: 1px solid #bbdefb; - margin-bottom: 16px; + border-left: 4px solid #007bff; } -.migration-status i { - margin-right: 8px; - color: #2196f3; +.migration-progress-status-label { + font-weight: 600; + color: #333; + margin-bottom: 5px; + font-size: 13px; } -.migration-footer { - padding: 16px 32px 24px; - border-top: 1px solid #e0e0e0; - background-color: #f8f9fa; +.migration-progress-status-text { + color: #555; + font-size: 14px; + line-height: 1.4; } -.migration-info { +.migration-progress-details { + margin-bottom: 20px; + padding: 12px; + background: #e3f2fd; + border-radius: 6px; + border-left: 4px solid #2196f3; +} + +.migration-progress-details-label { + font-weight: 600; + color: #1976d2; + margin-bottom: 5px; + font-size: 13px; +} + +.migration-progress-details-text { + color: #1565c0; + font-size: 13px; + line-height: 1.4; +} + +.migration-progress-footer { + padding: 20px 30px; + background: #f8f9fa; + border-top: 1px solid #e9ecef; +} + +.migration-progress-note { text-align: center; color: #666; font-size: 13px; - line-height: 1.4; - margin-bottom: 8px; -} - -.migration-info i { - margin-right: 6px; - color: #667eea; -} - -.migration-warning { - text-align: center; - color: #856404; - font-size: 12px; - line-height: 1.3; - background-color: #fff3cd; - padding: 8px 12px; - border-radius: 4px; - border: 1px solid #ffeaa7; -} - -.migration-warning i { - margin-right: 6px; - color: #f39c12; + font-style: italic; } /* Responsive design */ -@media (max-width: 768px) { - .migration-modal { - width: 98%; - margin: 10px; +@media (max-width: 600px) { + .migration-progress-modal { + width: 95%; + margin: 20px; } - .migration-header, - .migration-content, - .migration-footer { - padding-left: 16px; - padding-right: 16px; + .migration-progress-content { + padding: 20px; } - .migration-header h3 { - font-size: 20px; + .migration-progress-header { + padding: 15px; } - .step-header { - flex-direction: column; - align-items: flex-start; - } - - .step-progress { - text-align: left; - margin-top: 8px; - } - - .steps-list { - max-height: 200px; + .migration-progress-title { + font-size: 16px; } } + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .migration-progress-modal { + background: #2d3748; + color: #e2e8f0; + } + + .migration-progress-overall-label, + .migration-progress-step-label, + .migration-progress-status-label { + color: #e2e8f0; + } + + .migration-progress-status { + background: #4a5568; + border-left-color: #63b3ed; + } + + .migration-progress-status-text { + color: #cbd5e0; + } + + .migration-progress-details { + background: #2b6cb0; + border-left-color: #4299e1; + } + + .migration-progress-details-label { + color: #bee3f8; + } + + .migration-progress-details-text { + color: #90cdf4; + } + + .migration-progress-footer { + background: #4a5568; + border-top-color: #718096; + } + + .migration-progress-note { + color: #a0aec0; + } +} \ No newline at end of file diff --git a/client/components/migrationProgress.jade b/client/components/migrationProgress.jade index 274ea4621..250e20920 100644 --- a/client/components/migrationProgress.jade +++ b/client/components/migrationProgress.jade @@ -1,63 +1,43 @@ template(name="migrationProgress") - .migration-overlay(class="{{#if isMigrating}}active{{/if}}") - .migration-modal - .migration-header - h3 - | đŸ—„ī¸ - | {{_ 'database-migration'}} - p {{_ 'database-migration-description'}} - - .migration-content - .migration-overview - .overall-progress - .progress-bar - .progress-fill(style="width: {{migrationProgress}}%") - .progress-text {{migrationProgress}}% - .progress-label {{_ 'overall-progress'}} - - .current-step - | âš™ī¸ - | {{migrationCurrentStep}} - - .estimated-time(style="{{#unless migrationEstimatedTime}}display: none;{{/unless}}") - | ⏰ - | {{_ 'estimated-time-remaining'}}: {{migrationEstimatedTime}} + if isMigrating + .migration-progress-overlay + .migration-progress-modal + .migration-progress-header + h3.migration-progress-title + | 🔄 Board Migration in Progress + .migration-progress-close.js-close-migration-progress + | ❌ - .migration-steps - h4 {{_ 'migration-steps'}} - .steps-list - each migrationSteps - .migration-step(class="{{#if completed}}completed{{/if}}" class="{{#if isCurrentStep}}current{{/if}}") - .step-header - .step-icon - if completed - | ✅ - else if isCurrentStep - | âš™ī¸ - else - | ⭕ - .step-info - .step-name {{name}} - .step-description {{description}} - .step-progress - if completed - .progress-text 100% - else if isCurrentStep - .progress-text {{progress}}% - else - .progress-text 0% - if isCurrentStep - .step-progress-bar - .progress-fill(style="width: {{progress}}%") + .migration-progress-content + .migration-progress-overall + .migration-progress-overall-label + | Overall Progress: {{currentStep}} of {{totalSteps}} steps + .migration-progress-overall-bar + .migration-progress-overall-fill(style="{{progressBarStyle}}") + .migration-progress-overall-percentage + | {{overallProgress}}% + + .migration-progress-current-step + .migration-progress-step-label + | Current Step: {{stepNameFormatted}} + .migration-progress-step-bar + .migration-progress-step-fill(style="{{stepProgressBarStyle}}") + .migration-progress-step-percentage + | {{stepProgress}}% + + .migration-progress-status + .migration-progress-status-label + | Status: + .migration-progress-status-text + | {{stepStatus}} + + if stepDetailsFormatted + .migration-progress-details + .migration-progress-details-label + | Details: + .migration-progress-details-text + | {{stepDetailsFormatted}} - .migration-status - | â„šī¸ - | {{migrationStatus}} - - .migration-footer - .migration-info - | 💡 - | {{_ 'migration-info-text'}} - .migration-warning - | âš ī¸ - | {{_ 'migration-warning-text'}} + .migration-progress-footer + .migration-progress-note + | Please wait while we migrate your board to the latest structure... \ No newline at end of file diff --git a/client/components/migrationProgress.js b/client/components/migrationProgress.js index 83a05ea36..7c4064d39 100644 --- a/client/components/migrationProgress.js +++ b/client/components/migrationProgress.js @@ -1,54 +1,212 @@ -import { Template } from 'meteor/templating'; -import { - migrationManager, - isMigrating, - migrationProgress, - migrationStatus, - migrationCurrentStep, - migrationEstimatedTime, - migrationSteps -} from '/client/lib/migrationManager'; +/** + * Migration Progress Component + * Displays detailed progress for comprehensive board migration + */ +import { ReactiveVar } from 'meteor/reactive-var'; +import { ReactiveCache } from '/imports/reactiveCache'; + +// Reactive variables for migration progress +export const migrationProgress = new ReactiveVar(0); +export const migrationStatus = new ReactiveVar(''); +export const migrationStepName = new ReactiveVar(''); +export const migrationStepProgress = new ReactiveVar(0); +export const migrationStepStatus = new ReactiveVar(''); +export const migrationStepDetails = new ReactiveVar(null); +export const migrationCurrentStep = new ReactiveVar(0); +export const migrationTotalSteps = new ReactiveVar(0); +export const isMigrating = new ReactiveVar(false); + +class MigrationProgressManager { + constructor() { + this.progressHistory = []; + } + + /** + * Update migration progress + */ + updateProgress(progressData) { + const { + overallProgress, + currentStep, + totalSteps, + stepName, + stepProgress, + stepStatus, + stepDetails, + boardId + } = progressData; + + // Update reactive variables + migrationProgress.set(overallProgress); + migrationCurrentStep.set(currentStep); + migrationTotalSteps.set(totalSteps); + migrationStepName.set(stepName); + migrationStepProgress.set(stepProgress); + migrationStepStatus.set(stepStatus); + migrationStepDetails.set(stepDetails); + + // Store in history + this.progressHistory.push({ + timestamp: new Date(), + ...progressData + }); + + // Update overall status + migrationStatus.set(`${stepName}: ${stepStatus}`); + } + + /** + * Start migration + */ + startMigration() { + isMigrating.set(true); + migrationProgress.set(0); + migrationStatus.set('Starting migration...'); + migrationStepName.set(''); + migrationStepProgress.set(0); + migrationStepStatus.set(''); + migrationStepDetails.set(null); + migrationCurrentStep.set(0); + migrationTotalSteps.set(0); + this.progressHistory = []; + } + + /** + * Complete migration + */ + completeMigration() { + isMigrating.set(false); + migrationProgress.set(100); + migrationStatus.set('Migration completed successfully!'); + + // Clear step details after a delay + setTimeout(() => { + migrationStepName.set(''); + migrationStepProgress.set(0); + migrationStepStatus.set(''); + migrationStepDetails.set(null); + migrationCurrentStep.set(0); + migrationTotalSteps.set(0); + }, 3000); + } + + /** + * Fail migration + */ + failMigration(error) { + isMigrating.set(false); + migrationStatus.set(`Migration failed: ${error.message || error}`); + migrationStepStatus.set('Error occurred'); + } + + /** + * Get progress history + */ + getProgressHistory() { + return this.progressHistory; + } + + /** + * Clear progress + */ + clearProgress() { + isMigrating.set(false); + migrationProgress.set(0); + migrationStatus.set(''); + migrationStepName.set(''); + migrationStepProgress.set(0); + migrationStepStatus.set(''); + migrationStepDetails.set(null); + migrationCurrentStep.set(0); + migrationTotalSteps.set(0); + this.progressHistory = []; + } +} + +// Export singleton instance +export const migrationProgressManager = new MigrationProgressManager(); + +// Template helpers Template.migrationProgress.helpers({ isMigrating() { return isMigrating.get(); }, - - migrationProgress() { + + overallProgress() { return migrationProgress.get(); }, - - migrationStatus() { + + overallStatus() { return migrationStatus.get(); }, - - migrationCurrentStep() { + + currentStep() { return migrationCurrentStep.get(); }, - - migrationEstimatedTime() { - return migrationEstimatedTime.get(); + + totalSteps() { + return migrationTotalSteps.get(); }, - - migrationSteps() { - const steps = migrationSteps.get(); - const currentStep = migrationCurrentStep.get(); + + stepName() { + return migrationStepName.get(); + }, + + stepProgress() { + return migrationStepProgress.get(); + }, + + stepStatus() { + return migrationStepStatus.get(); + }, + + stepDetails() { + return migrationStepDetails.get(); + }, + + progressBarStyle() { + const progress = migrationProgress.get(); + return `width: ${progress}%`; + }, + + stepProgressBarStyle() { + const progress = migrationStepProgress.get(); + return `width: ${progress}%`; + }, + + stepNameFormatted() { + const stepName = migrationStepName.get(); + if (!stepName) return ''; - return steps.map(step => ({ - ...step, - isCurrentStep: step.name === currentStep - })); + // Convert snake_case to Title Case + return stepName + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }, + + stepDetailsFormatted() { + const details = migrationStepDetails.get(); + if (!details) return ''; + + const formatted = []; + for (const [key, value] of Object.entries(details)) { + const formattedKey = key + .split(/(?=[A-Z])/) + .join(' ') + .toLowerCase() + .replace(/^\w/, c => c.toUpperCase()); + formatted.push(`${formattedKey}: ${value}`); + } + + return formatted.join(', '); } }); -Template.migrationProgress.onCreated(function() { - // Subscribe to migration state changes - this.autorun(() => { - isMigrating.get(); - migrationProgress.get(); - migrationStatus.get(); - migrationCurrentStep.get(); - migrationEstimatedTime.get(); - migrationSteps.get(); - }); -}); +// Template events +Template.migrationProgress.events({ + 'click .js-close-migration-progress'() { + migrationProgressManager.clearProgress(); + } +}); \ No newline at end of file diff --git a/client/components/swimlanes/swimlanes.css b/client/components/swimlanes/swimlanes.css index 4c20cb0f4..83540549f 100644 --- a/client/components/swimlanes/swimlanes.css +++ b/client/components/swimlanes/swimlanes.css @@ -112,7 +112,7 @@ padding: 7px; top: 50%; transform: translateY(-50%); - left: 87vw; + right: 10px; font-size: 24px; cursor: move; z-index: 15; diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade index b1bc7e2d4..b61eb5033 100644 --- a/client/components/users/userAvatar.jade +++ b/client/components/users/userAvatar.jade @@ -87,7 +87,7 @@ template(name="changeAvatarPopup") each uploadedAvatars li: a.js-select-avatar .member - img.avatar.avatar-image(src="{{link}}?auth=false&brokenIsFine=true") + img.avatar.avatar-image(src="{{link}}") | {{_ 'uploaded-avatar'}} if isSelected | ✅ diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js index 98ebc901e..2869a9750 100644 --- a/client/components/users/userAvatar.js +++ b/client/components/users/userAvatar.js @@ -179,7 +179,7 @@ BlazeComponent.extendComponent({ isSelected() { const userProfile = ReactiveCache.getCurrentUser().profile; const avatarUrl = userProfile && userProfile.avatarUrl; - const currentAvatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`; + const currentAvatarUrl = this.currentData().link(); return avatarUrl === currentAvatarUrl; }, @@ -220,7 +220,7 @@ BlazeComponent.extendComponent({ } }, 'click .js-select-avatar'() { - const avatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`; + const avatarUrl = this.currentData().link(); this.setAvatar(avatarUrl); }, 'click .js-select-initials'() { diff --git a/models/attachments.js b/models/attachments.js index ac66c15c4..27d533e25 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -13,6 +13,7 @@ import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM // import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy'; import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility'; import AttachmentStorageSettings from './attachmentStorageSettings'; +import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator'; let attachmentUploadExternalProgram; let attachmentUploadMimeTypes = []; @@ -325,4 +326,15 @@ if (Meteor.isServer) { Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility; Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility; +// Override the link method to use universal URLs +if (Meteor.isClient) { + // Add custom link method to attachment documents + Attachments.collection.helpers({ + link(version = 'original') { + // Use universal URL generator for consistent, URL-agnostic URLs + return generateUniversalAttachmentUrl(this._id, version); + } + }); +} + export default Attachments; diff --git a/models/avatars.js b/models/avatars.js index 065728322..6ce904bcb 100644 --- a/models/avatars.js +++ b/models/avatars.js @@ -8,6 +8,7 @@ import { TAPi18n } from '/imports/i18n'; import fs from 'fs'; import path from 'path'; import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy'; +import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator'; const filesize = require('filesize'); @@ -116,7 +117,9 @@ Avatars = new FilesCollection({ const isValid = Promise.await(isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram)); if (isValid) { - ReactiveCache.getUser(fileObj.userId).setAvatarUrl(`${formatFleURL(fileObj)}?auth=false&brokenIsFine=true`); + // Set avatar URL using universal URL generator (URL-agnostic) + const universalUrl = generateUniversalAvatarUrl(fileObj._id); + ReactiveCache.getUser(fileObj.userId).setAvatarUrl(universalUrl); } else { Avatars.remove(fileObj._id); } @@ -164,4 +167,15 @@ if (Meteor.isServer) { }); } +// Override the link method to use universal URLs +if (Meteor.isClient) { + // Add custom link method to avatar documents + Avatars.collection.helpers({ + link(version = 'original') { + // Use universal URL generator for consistent, URL-agnostic URLs + return generateUniversalAvatarUrl(this._id, version); + } + }); +} + export default Avatars; diff --git a/models/lib/universalUrlGenerator.js b/models/lib/universalUrlGenerator.js new file mode 100644 index 000000000..16a8d0030 --- /dev/null +++ b/models/lib/universalUrlGenerator.js @@ -0,0 +1,194 @@ +/** + * Universal URL Generator + * Generates file URLs that work regardless of ROOT_URL and PORT settings + * Ensures all attachments and avatars are always visible + */ + +import { Meteor } from 'meteor/meteor'; + +/** + * Generate a universal file URL that works regardless of ROOT_URL and PORT + * @param {string} fileId - The file ID + * @param {string} type - The file type ('attachment' or 'avatar') + * @param {string} version - The file version (default: 'original') + * @returns {string} - Universal file URL + */ +export function generateUniversalFileUrl(fileId, type, version = 'original') { + if (!fileId) { + return ''; + } + + // Always use relative URLs to avoid ROOT_URL and PORT dependencies + if (type === 'attachment') { + return `/cdn/storage/attachments/${fileId}`; + } else if (type === 'avatar') { + return `/cdn/storage/avatars/${fileId}`; + } + + return ''; +} + +/** + * Generate a universal attachment URL + * @param {string} attachmentId - The attachment ID + * @param {string} version - The file version (default: 'original') + * @returns {string} - Universal attachment URL + */ +export function generateUniversalAttachmentUrl(attachmentId, version = 'original') { + return generateUniversalFileUrl(attachmentId, 'attachment', version); +} + +/** + * Generate a universal avatar URL + * @param {string} avatarId - The avatar ID + * @param {string} version - The file version (default: 'original') + * @returns {string} - Universal avatar URL + */ +export function generateUniversalAvatarUrl(avatarId, version = 'original') { + return generateUniversalFileUrl(avatarId, 'avatar', version); +} + +/** + * Clean and normalize a file URL to ensure it's universal + * @param {string} url - The URL to clean + * @param {string} type - The file type ('attachment' or 'avatar') + * @returns {string} - Cleaned universal URL + */ +export function cleanFileUrl(url, type) { + if (!url) { + return ''; + } + + // Remove any domain, port, or protocol from the URL + let cleanUrl = url; + + // Remove protocol and domain + cleanUrl = cleanUrl.replace(/^https?:\/\/[^\/]+/, ''); + + // Remove ROOT_URL pathname if present + if (Meteor.isServer && process.env.ROOT_URL) { + try { + const rootUrl = new URL(process.env.ROOT_URL); + if (rootUrl.pathname && rootUrl.pathname !== '/') { + cleanUrl = cleanUrl.replace(rootUrl.pathname, ''); + } + } catch (e) { + // Ignore URL parsing errors + } + } + + // Normalize path separators + cleanUrl = cleanUrl.replace(/\/+/g, '/'); + + // Ensure URL starts with / + if (!cleanUrl.startsWith('/')) { + cleanUrl = '/' + cleanUrl; + } + + // Convert old CollectionFS URLs to new format + if (type === 'attachment') { + cleanUrl = cleanUrl.replace('/cfs/files/attachments/', '/cdn/storage/attachments/'); + } else if (type === 'avatar') { + cleanUrl = cleanUrl.replace('/cfs/files/avatars/', '/cdn/storage/avatars/'); + } + + // Remove any query parameters that might cause issues + cleanUrl = cleanUrl.split('?')[0]; + cleanUrl = cleanUrl.split('#')[0]; + + return cleanUrl; +} + +/** + * Check if a URL is a universal file URL + * @param {string} url - The URL to check + * @param {string} type - The file type ('attachment' or 'avatar') + * @returns {boolean} - True if it's a universal file URL + */ +export function isUniversalFileUrl(url, type) { + if (!url) { + return false; + } + + if (type === 'attachment') { + return url.includes('/cdn/storage/attachments/') || url.includes('/cfs/files/attachments/'); + } else if (type === 'avatar') { + return url.includes('/cdn/storage/avatars/') || url.includes('/cfs/files/avatars/'); + } + + return false; +} + +/** + * Extract file ID from a universal file URL + * @param {string} url - The URL to extract from + * @param {string} type - The file type ('attachment' or 'avatar') + * @returns {string|null} - The file ID or null if not found + */ +export function extractFileIdFromUrl(url, type) { + if (!url) { + return null; + } + + let pattern; + if (type === 'attachment') { + pattern = /\/(?:cdn\/storage\/attachments|cfs\/files\/attachments)\/([^\/\?#]+)/; + } else if (type === 'avatar') { + pattern = /\/(?:cdn\/storage\/avatars|cfs\/files\/avatars)\/([^\/\?#]+)/; + } else { + return null; + } + + const match = url.match(pattern); + return match ? match[1] : null; +} + +/** + * Generate a fallback URL for when the primary URL fails + * @param {string} fileId - The file ID + * @param {string} type - The file type ('attachment' or 'avatar') + * @returns {string} - Fallback URL + */ +export function generateFallbackUrl(fileId, type) { + if (!fileId) { + return ''; + } + + // Try alternative route patterns + if (type === 'attachment') { + return `/attachments/${fileId}`; + } else if (type === 'avatar') { + return `/avatars/${fileId}`; + } + + return ''; +} + +/** + * Get all possible URLs for a file (for redundancy) + * @param {string} fileId - The file ID + * @param {string} type - The file type ('attachment' or 'avatar') + * @returns {Array} - Array of possible URLs + */ +export function getAllPossibleUrls(fileId, type) { + if (!fileId) { + return []; + } + + const urls = []; + + // Primary URL + urls.push(generateUniversalFileUrl(fileId, type)); + + // Fallback URL + urls.push(generateFallbackUrl(fileId, type)); + + // Legacy URLs for backward compatibility + if (type === 'attachment') { + urls.push(`/cfs/files/attachments/${fileId}`); + } else if (type === 'avatar') { + urls.push(`/cfs/files/avatars/${fileId}`); + } + + return urls.filter(url => url); // Remove empty URLs +} diff --git a/server/00checkStartup.js b/server/00checkStartup.js index d7035dca8..ed4dbabb3 100644 --- a/server/00checkStartup.js +++ b/server/00checkStartup.js @@ -42,6 +42,12 @@ import './cronJobStorage'; // Import migrations import './migrations/fixMissingListsMigration'; +import './migrations/fixAvatarUrls'; +import './migrations/fixAllFileUrls'; +import './migrations/comprehensiveBoardMigration'; + +// Import file serving routes +import './routes/universalFileServer'; // Note: Automatic migrations are disabled - migrations only run when opening boards // import './boardMigrationDetector'; diff --git a/server/cors.js b/server/cors.js index 4badba9fe..f99258eae 100644 --- a/server/cors.js +++ b/server/cors.js @@ -1,4 +1,19 @@ Meteor.startup(() => { + // Set Permissions-Policy header to suppress browser warnings about experimental features + WebApp.rawConnectHandlers.use(function(req, res, next) { + // Disable experimental advertising and privacy features that cause browser warnings + res.setHeader('Permissions-Policy', + 'browsing-topics=(), ' + + 'run-ad-auction=(), ' + + 'join-ad-interest-group=(), ' + + 'private-state-token-redemption=(), ' + + 'private-state-token-issuance=(), ' + + 'private-aggregation=(), ' + + 'attribution-reporting=()' + ); + return next(); + }); + if (process.env.CORS) { // Listen to incoming HTTP requests, can only be used on the server WebApp.rawConnectHandlers.use(function(req, res, next) { diff --git a/server/migrations/comprehensiveBoardMigration.js b/server/migrations/comprehensiveBoardMigration.js new file mode 100644 index 000000000..f9ea7c523 --- /dev/null +++ b/server/migrations/comprehensiveBoardMigration.js @@ -0,0 +1,767 @@ +/** + * 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 + * - v8.03+: Per-swimlane lists structure + */ + +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { ReactiveCache } from '/imports/reactiveCache'; +import Boards from '/models/boards'; +import Lists from '/models/lists'; +import Cards from '/models/cards'; +import Swimlanes from '/models/swimlanes'; +import Attachments from '/models/attachments'; +import { generateUniversalAttachmentUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator'; + +class ComprehensiveBoardMigration { + constructor() { + this.name = 'comprehensive-board-migration'; + this.version = 1; + this.migrationSteps = [ + 'analyze_board_structure', + 'fix_orphaned_cards', + 'convert_shared_lists', + 'ensure_per_swimlane_lists', + 'cleanup_empty_lists', + 'validate_migration' + ]; + } + + /** + * Check if migration is needed for a board + */ + needsMigration(boardId) { + try { + const board = ReactiveCache.getBoard(boardId); + if (!board) return false; + + // Check if board has already been processed + if (board.comprehensiveMigrationCompleted) { + return false; + } + + // Check for various issues that need migration + const issues = this.detectMigrationIssues(boardId); + return issues.length > 0; + + } catch (error) { + console.error('Error checking if migration is needed:', error); + return false; + } + } + + /** + * Detect all migration issues in a board + */ + detectMigrationIssues(boardId) { + const issues = []; + + try { + const cards = ReactiveCache.getCards({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + const swimlanes = ReactiveCache.getSwimlanes({ boardId }); + + // Issue 1: Cards with missing swimlaneId + const cardsWithoutSwimlane = cards.filter(card => !card.swimlaneId); + if (cardsWithoutSwimlane.length > 0) { + issues.push({ + type: 'cards_without_swimlane', + count: cardsWithoutSwimlane.length, + description: `${cardsWithoutSwimlane.length} cards missing swimlaneId` + }); + } + + // Issue 2: Cards with missing listId + const cardsWithoutList = cards.filter(card => !card.listId); + if (cardsWithoutList.length > 0) { + issues.push({ + type: 'cards_without_list', + count: cardsWithoutList.length, + description: `${cardsWithoutList.length} cards missing listId` + }); + } + + // Issue 3: Lists without swimlaneId (shared lists) + const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === ''); + if (sharedLists.length > 0) { + issues.push({ + type: 'shared_lists', + count: sharedLists.length, + description: `${sharedLists.length} lists without swimlaneId (shared lists)` + }); + } + + // Issue 4: Cards with mismatched listId/swimlaneId + const listSwimlaneMap = new Map(); + lists.forEach(list => { + listSwimlaneMap.set(list._id, list.swimlaneId || ''); + }); + + const mismatchedCards = cards.filter(card => { + if (!card.listId || !card.swimlaneId) return false; + const listSwimlaneId = listSwimlaneMap.get(card.listId); + return listSwimlaneId && listSwimlaneId !== card.swimlaneId; + }); + + if (mismatchedCards.length > 0) { + issues.push({ + type: 'mismatched_cards', + count: mismatchedCards.length, + description: `${mismatchedCards.length} cards with mismatched listId/swimlaneId` + }); + } + + // Issue 5: Empty lists (lists with no cards) + const emptyLists = lists.filter(list => { + const listCards = cards.filter(card => card.listId === list._id); + return listCards.length === 0; + }); + + if (emptyLists.length > 0) { + issues.push({ + type: 'empty_lists', + count: emptyLists.length, + description: `${emptyLists.length} empty lists (no cards)` + }); + } + + } catch (error) { + console.error('Error detecting migration issues:', error); + issues.push({ + type: 'detection_error', + count: 1, + description: `Error detecting issues: ${error.message}` + }); + } + + return issues; + } + + /** + * Execute the comprehensive migration for a board + */ + async executeMigration(boardId, progressCallback = null) { + try { + if (process.env.DEBUG === 'true') { + console.log(`Starting comprehensive board migration for board ${boardId}`); + } + + const board = ReactiveCache.getBoard(boardId); + if (!board) { + throw new Error(`Board ${boardId} not found`); + } + + const results = { + boardId, + steps: {}, + totalCardsProcessed: 0, + totalListsProcessed: 0, + totalListsCreated: 0, + totalListsRemoved: 0, + errors: [] + }; + + const totalSteps = this.migrationSteps.length; + let currentStep = 0; + + // Helper function to update progress + const updateProgress = (stepName, stepProgress, stepStatus, stepDetails = null) => { + currentStep++; + const overallProgress = Math.round((currentStep / totalSteps) * 100); + + const progressData = { + overallProgress, + currentStep: currentStep, + totalSteps, + stepName, + stepProgress, + stepStatus, + stepDetails, + boardId + }; + + if (progressCallback) { + progressCallback(progressData); + } + + if (process.env.DEBUG === 'true') { + console.log(`Migration Progress: ${stepName} - ${stepStatus} (${stepProgress}%)`); + } + }; + + // Step 1: Analyze board structure + updateProgress('analyze_board_structure', 0, 'Starting analysis...'); + results.steps.analyze = await this.analyzeBoardStructure(boardId); + updateProgress('analyze_board_structure', 100, 'Analysis complete', { + 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) => { + updateProgress('fix_orphaned_cards', progress, status); + }); + results.totalCardsProcessed += results.steps.fixOrphanedCards.cardsFixed || 0; + updateProgress('fix_orphaned_cards', 100, 'Orphaned cards fixed', { + cardsFixed: results.steps.fixOrphanedCards.cardsFixed + }); + + // Step 3: Convert shared lists to per-swimlane lists + updateProgress('convert_shared_lists', 0, 'Converting shared lists...'); + results.steps.convertSharedLists = await this.convertSharedListsToPerSwimlane(boardId, (progress, status) => { + updateProgress('convert_shared_lists', progress, status); + }); + results.totalListsProcessed += results.steps.convertSharedLists.listsProcessed || 0; + results.totalListsCreated += results.steps.convertSharedLists.listsCreated || 0; + updateProgress('convert_shared_lists', 100, 'Shared lists converted', { + listsProcessed: results.steps.convertSharedLists.listsProcessed, + listsCreated: results.steps.convertSharedLists.listsCreated + }); + + // Step 4: Ensure all lists are per-swimlane + updateProgress('ensure_per_swimlane_lists', 0, 'Ensuring per-swimlane structure...'); + results.steps.ensurePerSwimlane = await this.ensurePerSwimlaneLists(boardId); + results.totalListsProcessed += results.steps.ensurePerSwimlane.listsProcessed || 0; + updateProgress('ensure_per_swimlane_lists', 100, 'Per-swimlane structure ensured', { + listsProcessed: results.steps.ensurePerSwimlane.listsProcessed + }); + + // Step 5: Cleanup empty lists + updateProgress('cleanup_empty_lists', 0, 'Cleaning up empty lists...'); + results.steps.cleanupEmpty = await this.cleanupEmptyLists(boardId); + results.totalListsRemoved += results.steps.cleanupEmpty.listsRemoved || 0; + updateProgress('cleanup_empty_lists', 100, 'Empty lists cleaned up', { + listsRemoved: results.steps.cleanupEmpty.listsRemoved + }); + + // Step 6: Validate migration + updateProgress('validate_migration', 0, 'Validating migration...'); + results.steps.validate = await this.validateMigration(boardId); + updateProgress('validate_migration', 100, 'Migration validated', { + migrationSuccessful: results.steps.validate.migrationSuccessful, + totalCards: results.steps.validate.totalCards, + totalLists: results.steps.validate.totalLists + }); + + // Step 7: Fix avatar URLs + updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...'); + results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId); + updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', { + avatarsFixed: results.steps.fixAvatarUrls.avatarsFixed + }); + + // Step 8: Fix attachment URLs + updateProgress('fix_attachment_urls', 0, 'Fixing attachment URLs...'); + results.steps.fixAttachmentUrls = await this.fixAttachmentUrls(boardId); + updateProgress('fix_attachment_urls', 100, 'Attachment URLs fixed', { + attachmentsFixed: results.steps.fixAttachmentUrls.attachmentsFixed + }); + + // Mark board as processed + Boards.update(boardId, { + $set: { + comprehensiveMigrationCompleted: true, + comprehensiveMigrationCompletedAt: new Date(), + comprehensiveMigrationResults: results + } + }); + + if (process.env.DEBUG === 'true') { + console.log(`Comprehensive board migration completed for board ${boardId}:`, results); + } + + return { + success: true, + results + }; + + } catch (error) { + console.error(`Error executing comprehensive migration for board ${boardId}:`, error); + throw error; + } + } + + /** + * Step 1: Analyze board structure + */ + async analyzeBoardStructure(boardId) { + const issues = this.detectMigrationIssues(boardId); + return { + issues, + issueCount: issues.length, + needsMigration: issues.length > 0 + }; + } + + /** + * Step 2: Fix orphaned cards (cards with missing swimlaneId or listId) + */ + async fixOrphanedCards(boardId, progressCallback = null) { + const cards = ReactiveCache.getCards({ boardId }); + const swimlanes = ReactiveCache.getSwimlanes({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + + let cardsFixed = 0; + const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0]; + const totalCards = cards.length; + + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + let needsUpdate = false; + const updates = {}; + + // Fix missing swimlaneId + if (!card.swimlaneId) { + updates.swimlaneId = defaultSwimlane._id; + needsUpdate = true; + } + + // Fix missing listId + if (!card.listId) { + // Find or create a default list for this swimlane + const swimlaneId = updates.swimlaneId || card.swimlaneId; + let defaultList = lists.find(list => + list.swimlaneId === swimlaneId && list.title === 'Default' + ); + + if (!defaultList) { + // Create a default list for this swimlane + const newListId = Lists.insert({ + title: 'Default', + boardId: boardId, + swimlaneId: swimlaneId, + sort: 0, + archived: false, + createdAt: new Date(), + modifiedAt: new Date(), + type: 'list' + }); + defaultList = { _id: newListId }; + } + + updates.listId = defaultList._id; + needsUpdate = true; + } + + if (needsUpdate) { + Cards.update(card._id, { + $set: { + ...updates, + modifiedAt: new Date() + } + }); + cardsFixed++; + } + + // Update progress + if (progressCallback && (i % 10 === 0 || i === totalCards - 1)) { + const progress = Math.round(((i + 1) / totalCards) * 100); + progressCallback(progress, `Processing card ${i + 1} of ${totalCards}...`); + } + } + + return { cardsFixed }; + } + + /** + * Step 3: Convert shared lists to per-swimlane lists + */ + async convertSharedListsToPerSwimlane(boardId, progressCallback = null) { + const cards = ReactiveCache.getCards({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + const swimlanes = ReactiveCache.getSwimlanes({ boardId }); + + let listsProcessed = 0; + let listsCreated = 0; + + // Group cards by swimlaneId + const cardsBySwimlane = new Map(); + cards.forEach(card => { + if (!cardsBySwimlane.has(card.swimlaneId)) { + cardsBySwimlane.set(card.swimlaneId, []); + } + cardsBySwimlane.get(card.swimlaneId).push(card); + }); + + const swimlaneEntries = Array.from(cardsBySwimlane.entries()); + const totalSwimlanes = swimlaneEntries.length; + + // Process each swimlane + for (let i = 0; i < swimlaneEntries.length; i++) { + const [swimlaneId, swimlaneCards] = swimlaneEntries[i]; + if (!swimlaneId) continue; + + if (progressCallback) { + const progress = Math.round(((i + 1) / totalSwimlanes) * 100); + progressCallback(progress, `Processing swimlane ${i + 1} of ${totalSwimlanes}...`); + } + + // Get existing lists for this swimlane + const existingLists = lists.filter(list => list.swimlaneId === swimlaneId); + const existingListTitles = new Set(existingLists.map(list => list.title)); + + // Group cards by their current listId + const cardsByListId = new Map(); + swimlaneCards.forEach(card => { + if (!cardsByListId.has(card.listId)) { + cardsByListId.set(card.listId, []); + } + cardsByListId.get(card.listId).push(card); + }); + + // For each listId used by cards in this swimlane + for (const [listId, cardsInList] of cardsByListId) { + const originalList = lists.find(l => l._id === listId); + if (!originalList) continue; + + // Check if this list's swimlaneId matches the card's swimlaneId + if (originalList.swimlaneId === swimlaneId) { + // List is already correctly assigned to this swimlane + listsProcessed++; + continue; + } + + // 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 = { + title: originalList.title, + boardId: boardId, + swimlaneId: swimlaneId, + sort: originalList.sort || 0, + archived: originalList.archived || false, + createdAt: new Date(), + modifiedAt: new Date(), + type: originalList.type || 'list' + }; + + // Copy other properties if they exist + if (originalList.color) newListData.color = originalList.color; + if (originalList.wipLimit) newListData.wipLimit = originalList.wipLimit; + if (originalList.wipLimitEnabled) newListData.wipLimitEnabled = originalList.wipLimitEnabled; + if (originalList.wipLimitSoft) newListData.wipLimitSoft = originalList.wipLimitSoft; + if (originalList.starred) newListData.starred = originalList.starred; + if (originalList.collapsed) newListData.collapsed = originalList.collapsed; + + // Insert the new list + const newListId = Lists.insert(newListData); + targetList = { _id: newListId, ...newListData }; + listsCreated++; + } + + // Update all cards in this group to use the correct listId + for (const card of cardsInList) { + Cards.update(card._id, { + $set: { + listId: targetList._id, + modifiedAt: new Date() + } + }); + } + + listsProcessed++; + } + } + + return { listsProcessed, listsCreated }; + } + + /** + * Step 4: Ensure all lists are per-swimlane + */ + async ensurePerSwimlaneLists(boardId) { + const lists = ReactiveCache.getLists({ boardId }); + const swimlanes = ReactiveCache.getSwimlanes({ boardId }); + const defaultSwimlane = swimlanes.find(s => s.title === 'Default') || swimlanes[0]; + + let listsProcessed = 0; + + for (const list of lists) { + if (!list.swimlaneId || list.swimlaneId === '') { + // Assign to default swimlane + Lists.update(list._id, { + $set: { + swimlaneId: defaultSwimlane._id, + modifiedAt: new Date() + } + }); + listsProcessed++; + } + } + + return { listsProcessed }; + } + + /** + * Step 5: Cleanup empty lists (lists with no cards) + */ + async cleanupEmptyLists(boardId) { + const lists = ReactiveCache.getLists({ boardId }); + const cards = ReactiveCache.getCards({ boardId }); + + let listsRemoved = 0; + + 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})`); + } + } + } + + return { listsRemoved }; + } + + /** + * Step 6: Validate migration + */ + async validateMigration(boardId) { + const issues = this.detectMigrationIssues(boardId); + const cards = ReactiveCache.getCards({ boardId }); + const lists = ReactiveCache.getLists({ boardId }); + + // Check that all cards have valid swimlaneId and listId + const validCards = cards.filter(card => card.swimlaneId && card.listId); + const invalidCards = cards.length - validCards.length; + + // Check that all lists have swimlaneId + const validLists = lists.filter(list => list.swimlaneId && list.swimlaneId !== ''); + const invalidLists = lists.length - validLists.length; + + return { + issuesRemaining: issues.length, + totalCards: cards.length, + validCards, + invalidCards, + totalLists: lists.length, + validLists, + invalidLists, + migrationSuccessful: issues.length === 0 && invalidCards === 0 && invalidLists === 0 + }; + } + + /** + * Step 7: Fix avatar URLs (remove problematic auth parameters and fix URL formats) + */ + async fixAvatarUrls(boardId) { + const users = ReactiveCache.getUsers({}); + let avatarsFixed = 0; + + for (const user of users) { + if (user.profile && user.profile.avatarUrl) { + 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 + cleanUrl = cleanUrl.replace(/[?&]auth=false/g, ''); + cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, ''); + cleanUrl = cleanUrl.replace(/\?&/g, '?'); + 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 + if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('/')) { + cleanUrl = `/cdn/storage/avatars/${avatarUrl}`; + needsUpdate = true; + } + } + + if (needsUpdate) { + // Update user's avatar URL + Users.update(user._id, { + $set: { + 'profile.avatarUrl': cleanUrl, + modifiedAt: new Date() + } + }); + + avatarsFixed++; + } + } + } + + return { avatarsFixed }; + } + + /** + * Step 8: Fix attachment URLs (remove problematic auth parameters and fix URL formats) + */ + async fixAttachmentUrls(boardId) { + const attachments = ReactiveCache.getAttachments({}); + let attachmentsFixed = 0; + + for (const attachment of attachments) { + // Check if attachment has URL field that needs fixing + if (attachment.url) { + 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 + cleanUrl = cleanUrl.replace(/[?&]auth=false/g, ''); + cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, ''); + cleanUrl = cleanUrl.replace(/\?&/g, '?'); + 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, { + $set: { + url: cleanUrl, + modifiedAt: new Date() + } + }); + + attachmentsFixed++; + } + } + } + + return { attachmentsFixed }; + } + + /** + * Get migration status for a board + */ + getMigrationStatus(boardId) { + try { + const board = ReactiveCache.getBoard(boardId); + if (!board) { + return { status: 'board_not_found' }; + } + + if (board.comprehensiveMigrationCompleted) { + return { + status: 'completed', + completedAt: board.comprehensiveMigrationCompletedAt, + results: board.comprehensiveMigrationResults + }; + } + + const needsMigration = this.needsMigration(boardId); + const issues = this.detectMigrationIssues(boardId); + + return { + status: needsMigration ? 'needed' : 'not_needed', + issues, + issueCount: issues.length + }; + + } catch (error) { + console.error('Error getting migration status:', error); + return { status: 'error', error: error.message }; + } + } +} + +// Export singleton instance +export const comprehensiveBoardMigration = new ComprehensiveBoardMigration(); + +// Meteor methods +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'); + } + + 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); + }, + + 'comprehensiveBoardMigration.fixAvatarUrls'(boardId) { + check(boardId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return comprehensiveBoardMigration.fixAvatarUrls(boardId); + } +}); diff --git a/server/migrations/fixAllFileUrls.js b/server/migrations/fixAllFileUrls.js new file mode 100644 index 000000000..caba86e68 --- /dev/null +++ b/server/migrations/fixAllFileUrls.js @@ -0,0 +1,277 @@ +/** + * Fix All File URLs Migration + * Ensures all attachment and avatar URLs are universal and work regardless of ROOT_URL and PORT settings + */ + +import { ReactiveCache } from '/imports/reactiveCache'; +import Users from '/models/users'; +import Attachments from '/models/attachments'; +import Avatars from '/models/avatars'; +import { generateUniversalAttachmentUrl, generateUniversalAvatarUrl, cleanFileUrl, extractFileIdFromUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator'; + +class FixAllFileUrlsMigration { + constructor() { + this.name = 'fixAllFileUrls'; + this.version = 1; + } + + /** + * Check if migration is needed + */ + needsMigration() { + // Check for problematic avatar URLs + const users = ReactiveCache.getUsers({}); + for (const user of users) { + if (user.profile && user.profile.avatarUrl) { + const avatarUrl = user.profile.avatarUrl; + if (this.hasProblematicUrl(avatarUrl)) { + return true; + } + } + } + + // Check for problematic attachment URLs in cards + const cards = ReactiveCache.getCards({}); + for (const card of cards) { + if (card.attachments) { + for (const attachment of card.attachments) { + if (attachment.url && this.hasProblematicUrl(attachment.url)) { + return true; + } + } + } + } + + return false; + } + + /** + * Check if a URL has problematic patterns + */ + 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 { + const rootUrl = new URL(process.env.ROOT_URL); + if (rootUrl.pathname && rootUrl.pathname !== '/' && url.includes(rootUrl.pathname)) { + return true; + } + } catch (e) { + // 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; + } + + /** + * Execute the migration + */ + async execute() { + let filesFixed = 0; + let errors = []; + + console.log(`Starting universal file URL migration...`); + + try { + // Fix avatar URLs + const avatarFixed = await this.fixAvatarUrls(); + filesFixed += avatarFixed; + + // Fix attachment URLs + const attachmentFixed = await this.fixAttachmentUrls(); + filesFixed += attachmentFixed; + + // Fix card attachment references + const cardFixed = await this.fixCardAttachmentUrls(); + filesFixed += cardFixed; + + } catch (error) { + console.error('Error during file URL migration:', error); + errors.push(error.message); + } + + console.log(`Universal file URL migration completed. Fixed ${filesFixed} file URLs.`); + + return { + success: errors.length === 0, + filesFixed, + errors + }; + } + + /** + * Fix avatar URLs in user profiles + */ + async fixAvatarUrls() { + const users = ReactiveCache.getUsers({}); + let avatarsFixed = 0; + + 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 + cleanUrl = generateUniversalAvatarUrl(fileId); + } else { + // Clean existing URL + cleanUrl = cleanFileUrl(avatarUrl, 'avatar'); + } + + if (cleanUrl && cleanUrl !== avatarUrl) { + // Update user's avatar URL + Users.update(user._id, { + $set: { + 'profile.avatarUrl': cleanUrl, + modifiedAt: new Date() + } + }); + + avatarsFixed++; + + if (process.env.DEBUG === 'true') { + console.log(`Fixed avatar URL for user ${user.username}: ${avatarUrl} -> ${cleanUrl}`); + } + } + } catch (error) { + console.error(`Error fixing avatar URL for user ${user.username}:`, error); + } + } + } + } + + return avatarsFixed; + } + + /** + * Fix attachment URLs in attachment records + */ + async fixAttachmentUrls() { + const attachments = ReactiveCache.getAttachments({}); + let attachmentsFixed = 0; + + for (const attachment of attachments) { + // Check if attachment has URL field that needs fixing + if (attachment.url && this.hasProblematicUrl(attachment.url)) { + try { + const fileId = attachment._id; + const cleanUrl = generateUniversalAttachmentUrl(fileId); + + if (cleanUrl && cleanUrl !== attachment.url) { + // Update attachment URL + Attachments.update(attachment._id, { + $set: { + url: cleanUrl, + modifiedAt: new Date() + } + }); + + attachmentsFixed++; + + if (process.env.DEBUG === 'true') { + console.log(`Fixed attachment URL: ${attachment.url} -> ${cleanUrl}`); + } + } + } catch (error) { + console.error(`Error fixing attachment URL for ${attachment._id}:`, error); + } + } + } + + return attachmentsFixed; + } + + /** + * Fix attachment URLs in card references + */ + async fixCardAttachmentUrls() { + const cards = ReactiveCache.getCards({}); + let cardsFixed = 0; + + for (const card of cards) { + if (card.attachments) { + let needsUpdate = false; + const updatedAttachments = card.attachments.map(attachment => { + if (attachment.url && this.hasProblematicUrl(attachment.url)) { + try { + const fileId = attachment._id || extractFileIdFromUrl(attachment.url, 'attachment'); + const cleanUrl = fileId ? generateUniversalAttachmentUrl(fileId) : cleanFileUrl(attachment.url, 'attachment'); + + if (cleanUrl && cleanUrl !== attachment.url) { + needsUpdate = true; + return { ...attachment, url: cleanUrl }; + } + } catch (error) { + console.error(`Error fixing card attachment URL:`, error); + } + } + return attachment; + }); + + if (needsUpdate) { + // Update card with fixed attachment URLs + Cards.update(card._id, { + $set: { + attachments: updatedAttachments, + modifiedAt: new Date() + } + }); + + cardsFixed++; + + if (process.env.DEBUG === 'true') { + console.log(`Fixed attachment URLs in card ${card._id}`); + } + } + } + } + + return cardsFixed; + } +} + +// Export singleton instance +export const fixAllFileUrlsMigration = new FixAllFileUrlsMigration(); + +// Meteor methods +Meteor.methods({ + 'fixAllFileUrls.execute'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixAllFileUrlsMigration.execute(); + }, + + 'fixAllFileUrls.needsMigration'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixAllFileUrlsMigration.needsMigration(); + } +}); diff --git a/server/migrations/fixAvatarUrls.js b/server/migrations/fixAvatarUrls.js new file mode 100644 index 000000000..f542903ed --- /dev/null +++ b/server/migrations/fixAvatarUrls.js @@ -0,0 +1,128 @@ +/** + * Fix Avatar URLs Migration + * Removes problematic auth parameters from existing avatar URLs + */ + +import { ReactiveCache } from '/imports/reactiveCache'; +import Users from '/models/users'; +import { generateUniversalAvatarUrl, cleanFileUrl, extractFileIdFromUrl, isUniversalFileUrl } from '/models/lib/universalUrlGenerator'; + +class FixAvatarUrlsMigration { + constructor() { + this.name = 'fixAvatarUrls'; + this.version = 1; + } + + /** + * Check if migration is needed + */ + needsMigration() { + const users = ReactiveCache.getUsers({}); + + for (const user of users) { + if (user.profile && user.profile.avatarUrl) { + const avatarUrl = user.profile.avatarUrl; + if (avatarUrl.includes('auth=false') || avatarUrl.includes('brokenIsFine=true')) { + return true; + } + } + } + + return false; + } + + /** + * Execute the migration + */ + async execute() { + const users = ReactiveCache.getUsers({}); + let avatarsFixed = 0; + + console.log(`Starting avatar URL fix migration...`); + + for (const user of users) { + if (user.profile && user.profile.avatarUrl) { + 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 + cleanUrl = cleanUrl.replace(/[?&]auth=false/g, ''); + cleanUrl = cleanUrl.replace(/[?&]brokenIsFine=true/g, ''); + cleanUrl = cleanUrl.replace(/\?&/g, '?'); + 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 + if (!avatarUrl.startsWith('http') && !avatarUrl.startsWith('/')) { + cleanUrl = `/cdn/storage/avatars/${avatarUrl}`; + 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, { + $set: { + 'profile.avatarUrl': cleanUrl, + modifiedAt: new Date() + } + }); + + avatarsFixed++; + + if (process.env.DEBUG === 'true') { + console.log(`Fixed avatar URL for user ${user.username}: ${avatarUrl} -> ${cleanUrl}`); + } + } + } + } + + console.log(`Avatar URL fix migration completed. Fixed ${avatarsFixed} avatar URLs.`); + + return { + success: true, + avatarsFixed + }; + } +} + +// Export singleton instance +export const fixAvatarUrlsMigration = new FixAvatarUrlsMigration(); + +// Meteor method +Meteor.methods({ + 'fixAvatarUrls.execute'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixAvatarUrlsMigration.execute(); + }, + + 'fixAvatarUrls.needsMigration'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return fixAvatarUrlsMigration.needsMigration(); + } +}); diff --git a/server/routes/avatarServer.js b/server/routes/avatarServer.js new file mode 100644 index 000000000..008ea573a --- /dev/null +++ b/server/routes/avatarServer.js @@ -0,0 +1,123 @@ +/** + * Avatar File Server + * Handles serving avatar files from the /cdn/storage/avatars/ path + */ + +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { ReactiveCache } from '/imports/reactiveCache'; +import Avatars from '/models/avatars'; +import { fileStoreStrategyFactory } from '/models/lib/fileStoreStrategy'; +import fs from 'fs'; +import path from 'path'; + +if (Meteor.isServer) { + // Handle avatar file downloads + WebApp.connectHandlers.use('/cdn/storage/avatars/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const fileName = req.params[0]; + + if (!fileName) { + res.writeHead(400); + res.end('Invalid avatar file name'); + return; + } + + // 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'); + return; + } + + // Get avatar file from database + const avatar = ReactiveCache.getAvatar(fileId); + if (!avatar) { + res.writeHead(404); + res.end('Avatar not found'); + return; + } + + // Check if user has permission to view this avatar + // For avatars, we allow viewing by any logged-in user + const userId = Meteor.userId(); + if (!userId) { + res.writeHead(401); + res.end('Authentication required'); + return; + } + + // Get file strategy + const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + res.writeHead(404); + res.end('Avatar file not found in storage'); + return; + } + + // Set appropriate headers + res.setHeader('Content-Type', avatar.type || 'image/jpeg'); + 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}"`) { + res.writeHead(304); + res.end(); + return; + } + + // Stream the file + res.writeHead(200); + readStream.pipe(res); + + readStream.on('error', (error) => { + console.error('Avatar stream error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Error reading avatar file'); + } + }); + + } catch (error) { + console.error('Avatar server error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal server error'); + } + } + }); + + // Handle legacy avatar URLs (from CollectionFS) + WebApp.connectHandlers.use('/cfs/files/avatars/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + 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); + res.end('Internal server error'); + } + }); + + console.log('Avatar server routes initialized'); +} diff --git a/server/routes/universalFileServer.js b/server/routes/universalFileServer.js new file mode 100644 index 000000000..2a2cb2e39 --- /dev/null +++ b/server/routes/universalFileServer.js @@ -0,0 +1,393 @@ +/** + * Universal File Server + * Ensures all attachments and avatars are always visible regardless of ROOT_URL and PORT settings + * Handles both new Meteor-Files and legacy CollectionFS file serving + */ + +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { ReactiveCache } from '/imports/reactiveCache'; +import Attachments from '/models/attachments'; +import Avatars from '/models/avatars'; +import { fileStoreStrategyFactory } from '/models/lib/fileStoreStrategy'; +import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility'; +import fs from 'fs'; +import path from 'path'; + +if (Meteor.isServer) { + console.log('Universal file server initializing...'); + + /** + * Helper function to set appropriate headers for file serving + */ + function setFileHeaders(res, fileObj, isAttachment = false) { + // Set content type + res.setHeader('Content-Type', fileObj.type || (isAttachment ? 'application/octet-stream' : 'image/jpeg')); + + // Set content length + res.setHeader('Content-Length', fileObj.size || 0); + + // Set cache headers + res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year + res.setHeader('ETag', `"${fileObj._id}"`); + + // Set security headers for attachments + if (isAttachment) { + const isSvgFile = fileObj.name && fileObj.name.toLowerCase().endsWith('.svg'); + const disposition = isSvgFile ? 'attachment' : 'inline'; + res.setHeader('Content-Disposition', `${disposition}; filename="${fileObj.name}"`); + + // Add security headers for SVG files + if (isSvgFile) { + res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none';"); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + } + } + } + + /** + * Helper function to handle conditional requests + */ + function handleConditionalRequest(req, res, fileObj) { + const ifNoneMatch = req.headers['if-none-match']; + if (ifNoneMatch && ifNoneMatch === `"${fileObj._id}"`) { + res.writeHead(304); + res.end(); + return true; + } + return false; + } + + /** + * Helper function to stream file with error handling + */ + function streamFile(res, readStream, fileObj) { + readStream.on('error', (error) => { + console.error('File stream error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Error reading file'); + } + }); + + readStream.on('end', () => { + if (!res.headersSent) { + res.writeHead(200); + } + }); + + readStream.pipe(res); + } + + // ============================================================================ + // NEW METEOR-FILES ROUTES (URL-agnostic) + // ============================================================================ + + /** + * Serve attachments from new Meteor-Files structure + * Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename} + */ + WebApp.connectHandlers.use('/cdn/storage/attachments/([^/]+)(?:/original/[^/]+)?', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const fileId = req.params[0]; + + if (!fileId) { + res.writeHead(400); + res.end('Invalid attachment file ID'); + return; + } + + // Get attachment from database + const attachment = ReactiveCache.getAttachment(fileId); + if (!attachment) { + res.writeHead(404); + res.end('Attachment not found'); + return; + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board) { + res.writeHead(404); + res.end('Board not found'); + return; + } + + // Check if user has permission to download + const userId = Meteor.userId(); + if (!board.isPublic() && (!userId || !board.hasMember(userId))) { + res.writeHead(403); + res.end('Access denied'); + return; + } + + // Handle conditional requests + if (handleConditionalRequest(req, res, attachment)) { + return; + } + + // Get file strategy and stream + const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + res.writeHead(404); + res.end('Attachment file not found in storage'); + return; + } + + // Set headers and stream file + setFileHeaders(res, attachment, true); + streamFile(res, readStream, attachment); + + } catch (error) { + console.error('Attachment server error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal server error'); + } + } + }); + + /** + * Serve avatars from new Meteor-Files structure + * Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename} + */ + WebApp.connectHandlers.use('/cdn/storage/avatars/([^/]+)(?:/original/[^/]+)?', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const fileId = req.params[0]; + + if (!fileId) { + res.writeHead(400); + res.end('Invalid avatar file ID'); + return; + } + + // Get avatar from database + const avatar = ReactiveCache.getAvatar(fileId); + if (!avatar) { + res.writeHead(404); + res.end('Avatar not found'); + return; + } + + // Check if user has permission to view this avatar + // For avatars, we allow viewing by any logged-in user + const userId = Meteor.userId(); + if (!userId) { + res.writeHead(401); + res.end('Authentication required'); + return; + } + + // Handle conditional requests + if (handleConditionalRequest(req, res, avatar)) { + return; + } + + // Get file strategy and stream + const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + res.writeHead(404); + res.end('Avatar file not found in storage'); + return; + } + + // Set headers and stream file + setFileHeaders(res, avatar, false); + streamFile(res, readStream, avatar); + + } catch (error) { + console.error('Avatar server error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal server error'); + } + } + }); + + // ============================================================================ + // LEGACY COLLECTIONFS ROUTES (Backward compatibility) + // ============================================================================ + + /** + * Serve legacy attachments from CollectionFS structure + * Route: /cfs/files/attachments/{attachmentId} + */ + WebApp.connectHandlers.use('/cfs/files/attachments/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const attachmentId = req.params[0]; + + if (!attachmentId) { + res.writeHead(400); + res.end('Invalid attachment ID'); + return; + } + + // Try to get attachment with backward compatibility + const attachment = getAttachmentWithBackwardCompatibility(attachmentId); + if (!attachment) { + res.writeHead(404); + res.end('Attachment not found'); + return; + } + + // Check permissions + const board = ReactiveCache.getBoard(attachment.meta.boardId); + if (!board) { + res.writeHead(404); + res.end('Board not found'); + return; + } + + // Check if user has permission to download + const userId = Meteor.userId(); + if (!board.isPublic() && (!userId || !board.hasMember(userId))) { + res.writeHead(403); + res.end('Access denied'); + return; + } + + // Handle conditional requests + if (handleConditionalRequest(req, res, attachment)) { + return; + } + + // For legacy attachments, try to get GridFS stream + const fileStream = getOldAttachmentStream(attachmentId); + if (fileStream) { + setFileHeaders(res, attachment, true); + streamFile(res, fileStream, attachment); + } else { + res.writeHead(404); + res.end('Legacy attachment file not found in GridFS'); + } + + } catch (error) { + console.error('Legacy attachment server error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal server error'); + } + } + }); + + /** + * Serve legacy avatars from CollectionFS structure + * Route: /cfs/files/avatars/{avatarId} + */ + WebApp.connectHandlers.use('/cfs/files/avatars/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + try { + const avatarId = req.params[0]; + + if (!avatarId) { + res.writeHead(400); + res.end('Invalid avatar ID'); + return; + } + + // 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 + // This is a fallback for old CollectionFS avatars + res.writeHead(404); + res.end('Avatar not found'); + return; + } + + // Check if user has permission to view this avatar + const userId = Meteor.userId(); + if (!userId) { + res.writeHead(401); + res.end('Authentication required'); + return; + } + + // Handle conditional requests + if (handleConditionalRequest(req, res, avatar)) { + return; + } + + // Get file strategy and stream + const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original'); + const readStream = strategy.getReadStream(); + + if (!readStream) { + res.writeHead(404); + res.end('Avatar file not found in storage'); + return; + } + + // Set headers and stream file + setFileHeaders(res, avatar, false); + streamFile(res, readStream, avatar); + + } catch (error) { + console.error('Legacy avatar server error:', error); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal server error'); + } + } + }); + + // ============================================================================ + // ALTERNATIVE ROUTES (For different URL patterns) + // ============================================================================ + + /** + * Alternative attachment route for different URL patterns + * Route: /attachments/{fileId} + */ + WebApp.connectHandlers.use('/attachments/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + // Redirect to standard route + const fileId = req.params[0]; + const newUrl = `/cdn/storage/attachments/${fileId}`; + res.writeHead(301, { 'Location': newUrl }); + res.end(); + }); + + /** + * Alternative avatar route for different URL patterns + * Route: /avatars/{fileId} + */ + WebApp.connectHandlers.use('/avatars/([^/]+)', (req, res, next) => { + if (req.method !== 'GET') { + return next(); + } + + // Redirect to standard route + const fileId = req.params[0]; + const newUrl = `/cdn/storage/avatars/${fileId}`; + res.writeHead(301, { 'Location': newUrl }); + res.end(); + }); + + console.log('Universal file server initialized successfully'); +}