2
0
Эх сурвалжийг харах

Some migrations and mobile fixes.

Thanks to xet7 !
Lauri Ojansivu 2 өдөр өмнө
parent
commit
30620d0ca4

+ 30 - 16
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 {

+ 149 - 53
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);
-      // }
-
-      // 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);
+      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);
+      }
 
-      // 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

+ 63 - 63
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;
   }

+ 44 - 22
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;

+ 161 - 264
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-progress-close {
+  cursor: pointer;
+  font-size: 16px;
+  opacity: 0.8;
+  transition: opacity 0.2s ease;
 }
 
-.migration-header p {
-  margin: 0;
-  font-size: 16px;
-  opacity: 0.9;
+.migration-progress-close:hover {
+  opacity: 1;
 }
 
-.migration-content {
-  padding: 24px 32px;
-  max-height: 60vh;
-  overflow-y: auto;
+.migration-progress-content {
+  padding: 30px;
 }
 
-.migration-overview {
-  margin-bottom: 32px;
-  padding: 20px;
-  background: #f8f9fa;
-  border-radius: 8px;
-  border-left: 4px solid #667eea;
+.migration-progress-overall {
+  margin-bottom: 25px;
 }
 
-.overall-progress {
-  margin-bottom: 20px;
+.migration-progress-overall-label {
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 8px;
+  font-size: 14px;
 }
 
-.progress-bar {
-  width: 100%;
+.migration-progress-overall-bar {
+  background: #e9ecef;
+  border-radius: 10px;
   height: 12px;
-  background-color: #e0e0e0;
-  border-radius: 6px;
   overflow: hidden;
-  margin-bottom: 8px;
-  position: relative;
+  margin-bottom: 5px;
 }
 
-.progress-fill {
+.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;
-}
-
-@keyframes shimmer {
-  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;
+  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
+  animation: migrationProgressShimmer 2s infinite;
 }
 
-.current-step i {
-  margin-right: 8px;
-  color: #667eea;
+@keyframes migrationProgressShimmer {
+  0% { transform: translateX(-100%); }
+  100% { transform: translateX(100%); }
 }
 
-.estimated-time {
-  text-align: center;
+.migration-progress-overall-percentage {
+  text-align: right;
+  font-size: 12px;
   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;
+.migration-progress-current-step {
+  margin-bottom: 25px;
 }
 
-@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 {
+.migration-progress-step-label {
   font-weight: 600;
   color: #333;
+  margin-bottom: 8px;
   font-size: 14px;
-  margin-bottom: 2px;
 }
 
-.step-description {
-  color: #666;
-  font-size: 12px;
-  line-height: 1.3;
+.migration-progress-step-bar {
+  background: #e9ecef;
+  border-radius: 8px;
+  height: 8px;
+  overflow: hidden;
+  margin-bottom: 5px;
 }
 
-.step-progress {
-  text-align: right;
-  min-width: 40px;
+.migration-progress-step-fill {
+  background: linear-gradient(90deg, #007bff, #0056b3);
+  height: 100%;
+  border-radius: 8px;
+  transition: width 0.3s ease;
 }
 
-.step-progress .progress-text {
+.migration-progress-step-percentage {
+  text-align: right;
   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-status {
+  margin-bottom: 20px;
+  padding: 15px;
+  background: #f8f9fa;
+  border-radius: 6px;
+  border-left: 4px solid #007bff;
 }
 
-.step-progress-bar .progress-fill {
-  height: 100%;
-  background: linear-gradient(90deg, #667eea, #764ba2);
-  border-radius: 2px;
-  transition: width 0.3s ease;
+.migration-progress-status-label {
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 5px;
+  font-size: 13px;
 }
 
-.migration-status {
-  text-align: center;
-  color: #333;
-  font-size: 16px;
-  background-color: #e3f2fd;
-  padding: 12px 16px;
-  border-radius: 6px;
-  border: 1px solid #bbdefb;
-  margin-bottom: 16px;
+.migration-progress-status-text {
+  color: #555;
+  font-size: 14px;
+  line-height: 1.4;
 }
 
-.migration-status i {
-  margin-right: 8px;
-  color: #2196f3;
+.migration-progress-details {
+  margin-bottom: 20px;
+  padding: 12px;
+  background: #e3f2fd;
+  border-radius: 6px;
+  border-left: 4px solid #2196f3;
 }
 
-.migration-footer {
-  padding: 16px 32px 24px;
-  border-top: 1px solid #e0e0e0;
-  background-color: #f8f9fa;
+.migration-progress-details-label {
+  font-weight: 600;
+  color: #1976d2;
+  margin-bottom: 5px;
+  font-size: 13px;
 }
 
-.migration-info {
-  text-align: center;
-  color: #666;
+.migration-progress-details-text {
+  color: #1565c0;
   font-size: 13px;
   line-height: 1.4;
-  margin-bottom: 8px;
 }
 
-.migration-info i {
-  margin-right: 6px;
-  color: #667eea;
+.migration-progress-footer {
+  padding: 20px 30px;
+  background: #f8f9fa;
+  border-top: 1px solid #e9ecef;
 }
 
-.migration-warning {
+.migration-progress-note {
   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;
+  color: #666;
+  font-size: 13px;
+  font-style: italic;
 }
 
-.migration-warning i {
-  margin-right: 6px;
-  color: #f39c12;
+/* Responsive design */
+@media (max-width: 600px) {
+  .migration-progress-modal {
+    width: 95%;
+    margin: 20px;
+  }
+  
+  .migration-progress-content {
+    padding: 20px;
+  }
+  
+  .migration-progress-header {
+    padding: 15px;
+  }
+  
+  .migration-progress-title {
+    font-size: 16px;
+  }
 }
 
-/* Responsive design */
-@media (max-width: 768px) {
-  .migration-modal {
-    width: 98%;
-    margin: 10px;
+/* Dark mode support */
+@media (prefers-color-scheme: dark) {
+  .migration-progress-modal {
+    background: #2d3748;
+    color: #e2e8f0;
   }
   
-  .migration-header,
-  .migration-content,
-  .migration-footer {
-    padding-left: 16px;
-    padding-right: 16px;
+  .migration-progress-overall-label,
+  .migration-progress-step-label,
+  .migration-progress-status-label {
+    color: #e2e8f0;
   }
   
-  .migration-header h3 {
-    font-size: 20px;
+  .migration-progress-status {
+    background: #4a5568;
+    border-left-color: #63b3ed;
   }
   
-  .step-header {
-    flex-direction: column;
-    align-items: flex-start;
+  .migration-progress-status-text {
+    color: #cbd5e0;
   }
   
-  .step-progress {
-    text-align: left;
-    margin-top: 8px;
+  .migration-progress-details {
+    background: #2b6cb0;
+    border-left-color: #4299e1;
   }
   
-  .steps-list {
-    max-height: 200px;
+  .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;
+  }
+}

+ 39 - 59
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'}}
+  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-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}}%
           
-          .current-step
-            | ⚙️
-            | {{migrationCurrentStep}}
+          .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}}%
           
-          .estimated-time(style="{{#unless migrationEstimatedTime}}display: none;{{/unless}}")
-            | ⏰
-            | {{_ 'estimated-time-remaining'}}: {{migrationEstimatedTime}}
-        
-        .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-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...

+ 196 - 38
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();
+  },
+
+  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}%`;
   },
-  
-  migrationSteps() {
-    const steps = migrationSteps.get();
-    const currentStep = migrationCurrentStep.get();
+
+  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();
+  }
+});

+ 1 - 1
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;

+ 1 - 1
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
           | ✅

+ 2 - 2
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'() {

+ 12 - 0
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;

+ 15 - 1
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;

+ 194 - 0
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<string>} - 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
+}

+ 6 - 0
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';

+ 15 - 0
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) {

+ 767 - 0
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);
+  }
+});

+ 277 - 0
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();
+  }
+});

+ 128 - 0
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();
+  }
+});

+ 123 - 0
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');
+}

+ 393 - 0
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');
+}