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
|
|
@ -42,6 +42,12 @@ import './cronJobStorage';
|
|||
|
||||
// Import migrations
|
||||
import './migrations/fixMissingListsMigration';
|
||||
import './migrations/fixAvatarUrls';
|
||||
import './migrations/fixAllFileUrls';
|
||||
import './migrations/comprehensiveBoardMigration';
|
||||
|
||||
// Import file serving routes
|
||||
import './routes/universalFileServer';
|
||||
|
||||
// Note: Automatic migrations are disabled - migrations only run when opening boards
|
||||
// import './boardMigrationDetector';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,19 @@
|
|||
Meteor.startup(() => {
|
||||
// Set Permissions-Policy header to suppress browser warnings about experimental features
|
||||
WebApp.rawConnectHandlers.use(function(req, res, next) {
|
||||
// Disable experimental advertising and privacy features that cause browser warnings
|
||||
res.setHeader('Permissions-Policy',
|
||||
'browsing-topics=(), ' +
|
||||
'run-ad-auction=(), ' +
|
||||
'join-ad-interest-group=(), ' +
|
||||
'private-state-token-redemption=(), ' +
|
||||
'private-state-token-issuance=(), ' +
|
||||
'private-aggregation=(), ' +
|
||||
'attribution-reporting=()'
|
||||
);
|
||||
return next();
|
||||
});
|
||||
|
||||
if (process.env.CORS) {
|
||||
// Listen to incoming HTTP requests, can only be used on the server
|
||||
WebApp.rawConnectHandlers.use(function(req, res, next) {
|
||||
|
|
|
|||
767
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