mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 07:20:12 +01:00
Some migrations and mobile fixes.
Some checks failed
Some checks failed
Thanks to xet7 !
This commit is contained in:
parent
bccc22c5fe
commit
30620d0ca4
20 changed files with 2638 additions and 542 deletions
|
|
@ -269,57 +269,71 @@
|
||||||
}
|
}
|
||||||
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
|
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
|
||||||
.board-wrapper.mobile-view {
|
.board-wrapper.mobile-view {
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
min-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-wrapper.mobile-view .board-canvas {
|
.board-wrapper.mobile-view .board-canvas {
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
min-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-wrapper.mobile-view .board-canvas.mobile-view .swimlane {
|
.board-wrapper.mobile-view .board-canvas.mobile-view .swimlane {
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
display: flex;
|
display: block !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100vw !important;
|
||||||
min-width: 100%;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 800px),
|
@media screen and (max-width: 800px),
|
||||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||||
.board-wrapper {
|
.board-wrapper {
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
min-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-wrapper .board-canvas {
|
.board-wrapper .board-canvas {
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
min-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-wrapper .board-canvas .swimlane {
|
.board-wrapper .board-canvas .swimlane {
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
display: flex;
|
display: block !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden !important;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
width: 100%;
|
width: 100vw !important;
|
||||||
min-width: 100%;
|
max-width: 100vw !important;
|
||||||
|
min-width: 100vw !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.calendar-event-green {
|
.calendar-event-green {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import dragscroll from '@wekanteam/dragscroll';
|
||||||
import { boardConverter } from '/client/lib/boardConverter';
|
import { boardConverter } from '/client/lib/boardConverter';
|
||||||
import { migrationManager } from '/client/lib/migrationManager';
|
import { migrationManager } from '/client/lib/migrationManager';
|
||||||
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
||||||
|
import { migrationProgressManager } from '/client/components/migrationProgress';
|
||||||
import Swimlanes from '/models/swimlanes';
|
import Swimlanes from '/models/swimlanes';
|
||||||
import Lists from '/models/lists';
|
import Lists from '/models/lists';
|
||||||
|
|
||||||
|
|
@ -98,61 +99,25 @@ BlazeComponent.extendComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if board needs migration based on migration version
|
// Check if board needs comprehensive migration
|
||||||
// DISABLED: Migration check and execution
|
const needsMigration = await this.checkComprehensiveMigration(boardId);
|
||||||
// const needsMigration = !board.migrationVersion || board.migrationVersion < 1;
|
|
||||||
|
|
||||||
// if (needsMigration) {
|
if (needsMigration) {
|
||||||
// // Start background migration for old boards
|
// Start comprehensive migration
|
||||||
// this.isMigrating.set(true);
|
this.isMigrating.set(true);
|
||||||
// await this.startBackgroundMigration(boardId);
|
const success = await this.executeComprehensiveMigration(boardId);
|
||||||
// this.isMigrating.set(false);
|
this.isMigrating.set(false);
|
||||||
// }
|
|
||||||
|
if (success) {
|
||||||
|
this.isBoardReady.set(true);
|
||||||
|
} else {
|
||||||
|
console.error('Comprehensive migration failed, setting ready to true anyway');
|
||||||
|
this.isBoardReady.set(true); // Still show board even if migration failed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isBoardReady.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if board needs conversion (for old structure)
|
|
||||||
// DISABLED: Board conversion logic
|
|
||||||
// if (boardConverter.isBoardConverted(boardId)) {
|
|
||||||
// if (process.env.DEBUG === 'true') {
|
|
||||||
// console.log(`Board ${boardId} has already been converted, skipping conversion`);
|
|
||||||
// }
|
|
||||||
// this.isBoardReady.set(true);
|
|
||||||
// } else {
|
|
||||||
// const needsConversion = boardConverter.needsConversion(boardId);
|
|
||||||
//
|
|
||||||
// if (needsConversion) {
|
|
||||||
// this.isConverting.set(true);
|
|
||||||
// const success = await boardConverter.convertBoard(boardId);
|
|
||||||
// this.isConverting.set(false);
|
|
||||||
//
|
|
||||||
// if (success) {
|
|
||||||
// this.isBoardReady.set(true);
|
|
||||||
// } else {
|
|
||||||
// console.error('Board conversion failed, setting ready to true anyway');
|
|
||||||
// this.isBoardReady.set(true); // Still show board even if conversion failed
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// this.isBoardReady.set(true);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Set board ready immediately since conversions are disabled
|
|
||||||
this.isBoardReady.set(true);
|
|
||||||
|
|
||||||
// Convert shared lists to per-swimlane lists if needed
|
|
||||||
// DISABLED: Shared lists conversion
|
|
||||||
// await this.convertSharedListsToPerSwimlane(boardId);
|
|
||||||
|
|
||||||
// Fix missing lists migration (for cards with wrong listId references)
|
|
||||||
// DISABLED: Missing lists fix
|
|
||||||
// await this.fixMissingLists(boardId);
|
|
||||||
|
|
||||||
// Fix duplicate lists created by WeKan 8.10
|
|
||||||
// DISABLED: Duplicate lists fix
|
|
||||||
// await this.fixDuplicateLists(boardId);
|
|
||||||
|
|
||||||
// Start attachment migration in background if needed
|
|
||||||
// DISABLED: Attachment migration
|
|
||||||
// this.startAttachmentMigrationIfNeeded(boardId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during board conversion check:', error);
|
console.error('Error during board conversion check:', error);
|
||||||
this.isConverting.set(false);
|
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) {
|
async startBackgroundMigration(boardId) {
|
||||||
try {
|
try {
|
||||||
// Start background migration using the cron system
|
// Start background migration using the cron system
|
||||||
|
|
|
||||||
|
|
@ -505,73 +505,73 @@
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mode .swimlane {
|
.mobile-mode .swimlane {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
margin: 0 0 2rem 0 !important;
|
margin: 0 0 2rem 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
float: none !important;
|
float: none !important;
|
||||||
clear: both !important;
|
clear: both !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mode .swimlane .swimlane-header {
|
.mobile-mode .swimlane .swimlane-header {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
margin: 0 0 1rem 0 !important;
|
margin: 0 0 1rem 0 !important;
|
||||||
padding: 1rem !important;
|
padding: 1rem !important;
|
||||||
font-size: clamp(18px, 2.5vw, 32px) !important;
|
font-size: clamp(18px, 2.5vw, 32px) !important;
|
||||||
font-weight: bold !important;
|
font-weight: bold !important;
|
||||||
border-bottom: 2px solid #ccc !important;
|
border-bottom: 2px solid #ccc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mode .swimlane .lists {
|
.mobile-mode .swimlane .lists {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mode .list {
|
.mobile-mode .list {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
margin: 0 0 2rem 0 !important;
|
margin: 0 0 2rem 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
float: none !important;
|
float: none !important;
|
||||||
clear: both !important;
|
clear: both !important;
|
||||||
border-left: none !important;
|
border-left: none !important;
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
border-top: none !important;
|
border-top: none !important;
|
||||||
border-bottom: 2px solid #ccc !important;
|
border-bottom: 2px solid #ccc !important;
|
||||||
flex: none !important;
|
flex: none !important;
|
||||||
flex-basis: auto !important;
|
flex-basis: auto !important;
|
||||||
flex-grow: 0 !important;
|
flex-grow: 0 !important;
|
||||||
flex-shrink: 0 !important;
|
flex-shrink: 0 !important;
|
||||||
position: static !important;
|
position: static !important;
|
||||||
left: auto !important;
|
left: auto !important;
|
||||||
right: auto !important;
|
right: auto !important;
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
bottom: auto !important;
|
bottom: auto !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-mode .list:first-child {
|
.mobile-mode .list:first-child {
|
||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
|
|
@ -667,9 +667,9 @@
|
||||||
flex-wrap: nowrap !important;
|
flex-wrap: nowrap !important;
|
||||||
align-items: stretch !important;
|
align-items: stretch !important;
|
||||||
justify-content: flex-start !important;
|
justify-content: flex-start !important;
|
||||||
width: 100% !important;
|
width: 100vw !important;
|
||||||
max-width: 100% !important;
|
max-width: 100vw !important;
|
||||||
min-width: 100% !important;
|
min-width: 100vw !important;
|
||||||
overflow-x: hidden !important;
|
overflow-x: hidden !important;
|
||||||
overflow-y: auto !important;
|
overflow-y: auto !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -641,17 +641,22 @@ body.list-resizing-active * {
|
||||||
.mini-list.mobile-view {
|
.mini-list.mobile-view {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list.mobile-view {
|
.list.mobile-view {
|
||||||
display: contents;
|
display: block !important;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
.list.mobile-view:first-child {
|
.list.mobile-view:first-child {
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
|
|
@ -659,9 +664,11 @@ body.list-resizing-active * {
|
||||||
.list.mobile-view.ui-sortable-helper {
|
.list.mobile-view.ui-sortable-helper {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list.mobile-view.ui-sortable-helper .list-header.ui-sortable-handle {
|
.list.mobile-view.ui-sortable-helper .list-header.ui-sortable-handle {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
|
@ -669,14 +676,17 @@ body.list-resizing-active * {
|
||||||
.list.mobile-view.placeholder {
|
.list.mobile-view.placeholder {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list.mobile-view .list-body {
|
.list.mobile-view .list-body {
|
||||||
padding: 15px 19px;
|
padding: 15px 19px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
}
|
}
|
||||||
.list.mobile-view .list-header {
|
.list.mobile-view .list-header {
|
||||||
/*Updated padding values for mobile devices, this should fix text grouping issue*/
|
/*Updated padding values for mobile devices, this should fix text grouping issue*/
|
||||||
|
|
@ -685,8 +695,9 @@ body.list-resizing-active * {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
/* Force grid layout for iPhone */
|
/* Force grid layout for iPhone */
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: 30px 1fr auto auto !important;
|
grid-template-columns: 30px 1fr auto auto !important;
|
||||||
|
|
@ -767,17 +778,22 @@ body.list-resizing-active * {
|
||||||
.mini-list {
|
.mini-list {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: contents;
|
display: block !important;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
.list:first-child {
|
.list:first-child {
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
|
|
@ -785,9 +801,11 @@ body.list-resizing-active * {
|
||||||
.list.ui-sortable-helper {
|
.list.ui-sortable-helper {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list.ui-sortable-helper .list-header.ui-sortable-handle {
|
.list.ui-sortable-helper .list-header.ui-sortable-handle {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
|
|
@ -795,14 +813,17 @@ body.list-resizing-active * {
|
||||||
.list.placeholder {
|
.list.placeholder {
|
||||||
flex: 0 0 60px;
|
flex: 0 0 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
border-left: 0px !important;
|
border-left: 0px !important;
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
|
display: block !important;
|
||||||
}
|
}
|
||||||
.list-body {
|
.list-body {
|
||||||
padding: 15px 19px;
|
padding: 15px 19px;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
}
|
}
|
||||||
.list-header {
|
.list-header {
|
||||||
/*Updated padding values for mobile devices, this should fix text grouping issue*/
|
/*Updated padding values for mobile devices, this should fix text grouping issue*/
|
||||||
|
|
@ -811,8 +832,9 @@ body.list-resizing-active * {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100vw;
|
||||||
min-width: 100%;
|
max-width: 100vw;
|
||||||
|
min-width: 100vw;
|
||||||
}
|
}
|
||||||
.list-header .list-header-left-icon {
|
.list-header .list-header-left-icon {
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,33 @@
|
||||||
/* Migration Progress Styles */
|
/* Migration Progress Styles */
|
||||||
.migration-overlay {
|
.migration-progress-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
right: 0;
|
||||||
height: 100%;
|
bottom: 0;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
z-index: 10000;
|
z-index: 9999;
|
||||||
display: none;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow-y: auto;
|
backdrop-filter: blur(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-overlay.active {
|
.migration-progress-modal {
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-modal {
|
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
max-width: 800px;
|
max-width: 500px;
|
||||||
width: 95%;
|
width: 90%;
|
||||||
max-height: 90vh;
|
max-height: 80vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: slideInScale 0.4s ease-out;
|
animation: migrationModalSlideIn 0.3s ease-out;
|
||||||
margin: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInScale {
|
@keyframes migrationModalSlideIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-30px) scale(0.95);
|
transform: translateY(-20px) scale(0.95);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|
@ -40,333 +35,235 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-header {
|
.migration-progress-header {
|
||||||
padding: 24px 32px 20px;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
text-align: center;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-header h3 {
|
.migration-progress-title {
|
||||||
margin: 0 0 8px 0;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-header h3 i {
|
.migration-progress-close {
|
||||||
margin-right: 12px;
|
cursor: pointer;
|
||||||
color: #FFD700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-header p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
opacity: 0.9;
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-content {
|
.migration-progress-close:hover {
|
||||||
padding: 24px 32px;
|
opacity: 1;
|
||||||
max-height: 60vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-overview {
|
.migration-progress-content {
|
||||||
margin-bottom: 32px;
|
padding: 30px;
|
||||||
padding: 20px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overall-progress {
|
.migration-progress-overall {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.migration-progress-overall-label {
|
||||||
width: 100%;
|
font-weight: 600;
|
||||||
height: 12px;
|
color: #333;
|
||||||
background-color: #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
position: relative;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.migration-progress-overall-bar {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-overall-fill {
|
||||||
|
background: linear-gradient(90deg, #28a745, #20c997);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
border-radius: 10px;
|
||||||
border-radius: 6px;
|
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill::after {
|
.migration-progress-overall-fill::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||||
90deg,
|
animation: migrationProgressShimmer 2s infinite;
|
||||||
transparent,
|
|
||||||
rgba(255, 255, 255, 0.4),
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
animation: shimmer 2s infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes migrationProgressShimmer {
|
||||||
0% {
|
0% { transform: translateX(-100%); }
|
||||||
transform: translateX(-100%);
|
100% { transform: translateX(100%); }
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-text {
|
.migration-progress-overall-percentage {
|
||||||
text-align: center;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #667eea;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-step {
|
|
||||||
text-align: center;
|
|
||||||
color: #333;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-step i {
|
|
||||||
margin-right: 8px;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time {
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
background-color: #fff3cd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time i {
|
|
||||||
margin-right: 6px;
|
|
||||||
color: #f39c12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-steps {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-steps h4 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-step {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-step:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-step.completed {
|
|
||||||
background-color: #d4edda;
|
|
||||||
border-left: 4px solid #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-step.current {
|
|
||||||
background-color: #cce7ff;
|
|
||||||
border-left: 4px solid #667eea;
|
|
||||||
animation: pulse 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
70% {
|
|
||||||
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon {
|
|
||||||
margin-right: 12px;
|
|
||||||
font-size: 18px;
|
|
||||||
width: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon i.fa-check-circle {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon i.fa-cog.fa-spin {
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-icon i.fa-circle-o {
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-description {
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-progress {
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-progress .progress-text {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-progress-bar {
|
.migration-progress-current-step {
|
||||||
width: 100%;
|
margin-bottom: 25px;
|
||||||
height: 4px;
|
|
||||||
background-color: #e0e0e0;
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-progress-bar .progress-fill {
|
.migration-progress-step-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-step-bar {
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-step-fill {
|
||||||
|
background: linear-gradient(90deg, #007bff, #0056b3);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
border-radius: 8px;
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-status {
|
.migration-progress-step-percentage {
|
||||||
text-align: center;
|
text-align: right;
|
||||||
color: #333;
|
font-size: 12px;
|
||||||
font-size: 16px;
|
color: #666;
|
||||||
background-color: #e3f2fd;
|
font-weight: 600;
|
||||||
padding: 12px 16px;
|
}
|
||||||
|
|
||||||
|
.migration-progress-status {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #bbdefb;
|
border-left: 4px solid #007bff;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-status i {
|
.migration-progress-status-label {
|
||||||
margin-right: 8px;
|
font-weight: 600;
|
||||||
color: #2196f3;
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-footer {
|
.migration-progress-status-text {
|
||||||
padding: 16px 32px 24px;
|
color: #555;
|
||||||
border-top: 1px solid #e0e0e0;
|
font-size: 14px;
|
||||||
background-color: #f8f9fa;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-info {
|
.migration-progress-details {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-details-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1976d2;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-details-text {
|
||||||
|
color: #1565c0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-footer {
|
||||||
|
padding: 20px 30px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-note {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
font-style: italic;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-info i {
|
|
||||||
margin-right: 6px;
|
|
||||||
color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-warning {
|
|
||||||
text-align: center;
|
|
||||||
color: #856404;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.3;
|
|
||||||
background-color: #fff3cd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ffeaa7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.migration-warning i {
|
|
||||||
margin-right: 6px;
|
|
||||||
color: #f39c12;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design */
|
/* Responsive design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 600px) {
|
||||||
.migration-modal {
|
.migration-progress-modal {
|
||||||
width: 98%;
|
width: 95%;
|
||||||
margin: 10px;
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-header,
|
.migration-progress-content {
|
||||||
.migration-content,
|
padding: 20px;
|
||||||
.migration-footer {
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.migration-header h3 {
|
.migration-progress-header {
|
||||||
font-size: 20px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-header {
|
.migration-progress-title {
|
||||||
flex-direction: column;
|
font-size: 16px;
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-progress {
|
|
||||||
text-align: left;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps-list {
|
|
||||||
max-height: 200px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.migration-progress-modal {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-overall-label,
|
||||||
|
.migration-progress-step-label,
|
||||||
|
.migration-progress-status-label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-status {
|
||||||
|
background: #4a5568;
|
||||||
|
border-left-color: #63b3ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-status-text {
|
||||||
|
color: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-details {
|
||||||
|
background: #2b6cb0;
|
||||||
|
border-left-color: #4299e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-details-label {
|
||||||
|
color: #bee3f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-details-text {
|
||||||
|
color: #90cdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-footer {
|
||||||
|
background: #4a5568;
|
||||||
|
border-top-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migration-progress-note {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,43 @@
|
||||||
template(name="migrationProgress")
|
template(name="migrationProgress")
|
||||||
.migration-overlay(class="{{#if isMigrating}}active{{/if}}")
|
if isMigrating
|
||||||
.migration-modal
|
.migration-progress-overlay
|
||||||
.migration-header
|
.migration-progress-modal
|
||||||
h3
|
.migration-progress-header
|
||||||
| 🗄️
|
h3.migration-progress-title
|
||||||
| {{_ 'database-migration'}}
|
| 🔄 Board Migration in Progress
|
||||||
p {{_ 'database-migration-description'}}
|
.migration-progress-close.js-close-migration-progress
|
||||||
|
| ❌
|
||||||
.migration-content
|
|
||||||
.migration-overview
|
|
||||||
.overall-progress
|
|
||||||
.progress-bar
|
|
||||||
.progress-fill(style="width: {{migrationProgress}}%")
|
|
||||||
.progress-text {{migrationProgress}}%
|
|
||||||
.progress-label {{_ 'overall-progress'}}
|
|
||||||
|
|
||||||
.current-step
|
|
||||||
| ⚙️
|
|
||||||
| {{migrationCurrentStep}}
|
|
||||||
|
|
||||||
.estimated-time(style="{{#unless migrationEstimatedTime}}display: none;{{/unless}}")
|
|
||||||
| ⏰
|
|
||||||
| {{_ 'estimated-time-remaining'}}: {{migrationEstimatedTime}}
|
|
||||||
|
|
||||||
.migration-steps
|
.migration-progress-content
|
||||||
h4 {{_ 'migration-steps'}}
|
.migration-progress-overall
|
||||||
.steps-list
|
.migration-progress-overall-label
|
||||||
each migrationSteps
|
| Overall Progress: {{currentStep}} of {{totalSteps}} steps
|
||||||
.migration-step(class="{{#if completed}}completed{{/if}}" class="{{#if isCurrentStep}}current{{/if}}")
|
.migration-progress-overall-bar
|
||||||
.step-header
|
.migration-progress-overall-fill(style="{{progressBarStyle}}")
|
||||||
.step-icon
|
.migration-progress-overall-percentage
|
||||||
if completed
|
| {{overallProgress}}%
|
||||||
| ✅
|
|
||||||
else if isCurrentStep
|
.migration-progress-current-step
|
||||||
| ⚙️
|
.migration-progress-step-label
|
||||||
else
|
| Current Step: {{stepNameFormatted}}
|
||||||
| ⭕
|
.migration-progress-step-bar
|
||||||
.step-info
|
.migration-progress-step-fill(style="{{stepProgressBarStyle}}")
|
||||||
.step-name {{name}}
|
.migration-progress-step-percentage
|
||||||
.step-description {{description}}
|
| {{stepProgress}}%
|
||||||
.step-progress
|
|
||||||
if completed
|
.migration-progress-status
|
||||||
.progress-text 100%
|
.migration-progress-status-label
|
||||||
else if isCurrentStep
|
| Status:
|
||||||
.progress-text {{progress}}%
|
.migration-progress-status-text
|
||||||
else
|
| {{stepStatus}}
|
||||||
.progress-text 0%
|
|
||||||
if isCurrentStep
|
if stepDetailsFormatted
|
||||||
.step-progress-bar
|
.migration-progress-details
|
||||||
.progress-fill(style="width: {{progress}}%")
|
.migration-progress-details-label
|
||||||
|
| Details:
|
||||||
|
.migration-progress-details-text
|
||||||
|
| {{stepDetailsFormatted}}
|
||||||
|
|
||||||
.migration-status
|
.migration-progress-footer
|
||||||
| ℹ️
|
.migration-progress-note
|
||||||
| {{migrationStatus}}
|
| Please wait while we migrate your board to the latest structure...
|
||||||
|
|
||||||
.migration-footer
|
|
||||||
.migration-info
|
|
||||||
| 💡
|
|
||||||
| {{_ 'migration-info-text'}}
|
|
||||||
.migration-warning
|
|
||||||
| ⚠️
|
|
||||||
| {{_ 'migration-warning-text'}}
|
|
||||||
|
|
@ -1,54 +1,212 @@
|
||||||
import { Template } from 'meteor/templating';
|
/**
|
||||||
import {
|
* Migration Progress Component
|
||||||
migrationManager,
|
* Displays detailed progress for comprehensive board migration
|
||||||
isMigrating,
|
*/
|
||||||
migrationProgress,
|
|
||||||
migrationStatus,
|
|
||||||
migrationCurrentStep,
|
|
||||||
migrationEstimatedTime,
|
|
||||||
migrationSteps
|
|
||||||
} from '/client/lib/migrationManager';
|
|
||||||
|
|
||||||
|
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({
|
Template.migrationProgress.helpers({
|
||||||
isMigrating() {
|
isMigrating() {
|
||||||
return isMigrating.get();
|
return isMigrating.get();
|
||||||
},
|
},
|
||||||
|
|
||||||
migrationProgress() {
|
overallProgress() {
|
||||||
return migrationProgress.get();
|
return migrationProgress.get();
|
||||||
},
|
},
|
||||||
|
|
||||||
migrationStatus() {
|
overallStatus() {
|
||||||
return migrationStatus.get();
|
return migrationStatus.get();
|
||||||
},
|
},
|
||||||
|
|
||||||
migrationCurrentStep() {
|
currentStep() {
|
||||||
return migrationCurrentStep.get();
|
return migrationCurrentStep.get();
|
||||||
},
|
},
|
||||||
|
|
||||||
migrationEstimatedTime() {
|
totalSteps() {
|
||||||
return migrationEstimatedTime.get();
|
return migrationTotalSteps.get();
|
||||||
},
|
},
|
||||||
|
|
||||||
migrationSteps() {
|
stepName() {
|
||||||
const steps = migrationSteps.get();
|
return migrationStepName.get();
|
||||||
const currentStep = migrationCurrentStep.get();
|
},
|
||||||
|
|
||||||
|
stepProgress() {
|
||||||
|
return migrationStepProgress.get();
|
||||||
|
},
|
||||||
|
|
||||||
|
stepStatus() {
|
||||||
|
return migrationStepStatus.get();
|
||||||
|
},
|
||||||
|
|
||||||
|
stepDetails() {
|
||||||
|
return migrationStepDetails.get();
|
||||||
|
},
|
||||||
|
|
||||||
|
progressBarStyle() {
|
||||||
|
const progress = migrationProgress.get();
|
||||||
|
return `width: ${progress}%`;
|
||||||
|
},
|
||||||
|
|
||||||
|
stepProgressBarStyle() {
|
||||||
|
const progress = migrationStepProgress.get();
|
||||||
|
return `width: ${progress}%`;
|
||||||
|
},
|
||||||
|
|
||||||
|
stepNameFormatted() {
|
||||||
|
const stepName = migrationStepName.get();
|
||||||
|
if (!stepName) return '';
|
||||||
|
|
||||||
return steps.map(step => ({
|
// Convert snake_case to Title Case
|
||||||
...step,
|
return stepName
|
||||||
isCurrentStep: step.name === currentStep
|
.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() {
|
// Template events
|
||||||
// Subscribe to migration state changes
|
Template.migrationProgress.events({
|
||||||
this.autorun(() => {
|
'click .js-close-migration-progress'() {
|
||||||
isMigrating.get();
|
migrationProgressManager.clearProgress();
|
||||||
migrationProgress.get();
|
}
|
||||||
migrationStatus.get();
|
});
|
||||||
migrationCurrentStep.get();
|
|
||||||
migrationEstimatedTime.get();
|
|
||||||
migrationSteps.get();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -112,7 +112,7 @@
|
||||||
padding: 7px;
|
padding: 7px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
left: 87vw;
|
right: 10px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ template(name="changeAvatarPopup")
|
||||||
each uploadedAvatars
|
each uploadedAvatars
|
||||||
li: a.js-select-avatar
|
li: a.js-select-avatar
|
||||||
.member
|
.member
|
||||||
img.avatar.avatar-image(src="{{link}}?auth=false&brokenIsFine=true")
|
img.avatar.avatar-image(src="{{link}}")
|
||||||
| {{_ 'uploaded-avatar'}}
|
| {{_ 'uploaded-avatar'}}
|
||||||
if isSelected
|
if isSelected
|
||||||
| ✅
|
| ✅
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ BlazeComponent.extendComponent({
|
||||||
isSelected() {
|
isSelected() {
|
||||||
const userProfile = ReactiveCache.getCurrentUser().profile;
|
const userProfile = ReactiveCache.getCurrentUser().profile;
|
||||||
const avatarUrl = userProfile && userProfile.avatarUrl;
|
const avatarUrl = userProfile && userProfile.avatarUrl;
|
||||||
const currentAvatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
|
const currentAvatarUrl = this.currentData().link();
|
||||||
return avatarUrl === currentAvatarUrl;
|
return avatarUrl === currentAvatarUrl;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -220,7 +220,7 @@ BlazeComponent.extendComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'click .js-select-avatar'() {
|
'click .js-select-avatar'() {
|
||||||
const avatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
|
const avatarUrl = this.currentData().link();
|
||||||
this.setAvatar(avatarUrl);
|
this.setAvatar(avatarUrl);
|
||||||
},
|
},
|
||||||
'click .js-select-initials'() {
|
'click .js-select-initials'() {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM
|
||||||
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
|
||||||
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
|
||||||
import AttachmentStorageSettings from './attachmentStorageSettings';
|
import AttachmentStorageSettings from './attachmentStorageSettings';
|
||||||
|
import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
|
||||||
|
|
||||||
let attachmentUploadExternalProgram;
|
let attachmentUploadExternalProgram;
|
||||||
let attachmentUploadMimeTypes = [];
|
let attachmentUploadMimeTypes = [];
|
||||||
|
|
@ -325,4 +326,15 @@ if (Meteor.isServer) {
|
||||||
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
|
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
|
||||||
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
|
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;
|
export default Attachments;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { TAPi18n } from '/imports/i18n';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
|
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
|
||||||
|
import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
|
||||||
|
|
||||||
const filesize = require('filesize');
|
const filesize = require('filesize');
|
||||||
|
|
||||||
|
|
@ -116,7 +117,9 @@ Avatars = new FilesCollection({
|
||||||
const isValid = Promise.await(isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram));
|
const isValid = Promise.await(isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram));
|
||||||
|
|
||||||
if (isValid) {
|
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 {
|
} else {
|
||||||
Avatars.remove(fileObj._id);
|
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;
|
export default Avatars;
|
||||||
|
|
|
||||||
194
models/lib/universalUrlGenerator.js
Normal file
194
models/lib/universalUrlGenerator.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,12 @@ import './cronJobStorage';
|
||||||
|
|
||||||
// Import migrations
|
// Import migrations
|
||||||
import './migrations/fixMissingListsMigration';
|
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
|
// Note: Automatic migrations are disabled - migrations only run when opening boards
|
||||||
// import './boardMigrationDetector';
|
// import './boardMigrationDetector';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,19 @@
|
||||||
Meteor.startup(() => {
|
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) {
|
if (process.env.CORS) {
|
||||||
// Listen to incoming HTTP requests, can only be used on the server
|
// Listen to incoming HTTP requests, can only be used on the server
|
||||||
WebApp.rawConnectHandlers.use(function(req, res, next) {
|
WebApp.rawConnectHandlers.use(function(req, res, next) {
|
||||||
|
|
|
||||||
767
server/migrations/comprehensiveBoardMigration.js
Normal file
767
server/migrations/comprehensiveBoardMigration.js
Normal file
|
|
@ -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
server/migrations/fixAllFileUrls.js
Normal file
277
server/migrations/fixAllFileUrls.js
Normal file
|
|
@ -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
server/migrations/fixAvatarUrls.js
Normal file
128
server/migrations/fixAvatarUrls.js
Normal file
|
|
@ -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
server/routes/avatarServer.js
Normal file
123
server/routes/avatarServer.js
Normal file
|
|
@ -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
server/routes/universalFileServer.js
Normal file
393
server/routes/universalFileServer.js
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue