Some migrations and mobile fixes.
Some checks failed
Docker / build (push) Has been cancelled
Docker Image CI / build (push) Has been cancelled
Release Charts / release (push) Has been cancelled
Test suite / Meteor tests (push) Has been cancelled
Test suite / Coverage report (push) Has been cancelled

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2025-10-25 21:09:07 +03:00
parent bccc22c5fe
commit 30620d0ca4
20 changed files with 2638 additions and 542 deletions

View file

@ -13,6 +13,7 @@ import FileStoreStrategyFactory, {moveToStorage, rename, STORAGE_NAME_FILESYSTEM
// import { STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
import { getAttachmentWithBackwardCompatibility, getAttachmentsWithBackwardCompatibility } from './lib/attachmentBackwardCompatibility';
import AttachmentStorageSettings from './attachmentStorageSettings';
import { generateUniversalAttachmentUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
let attachmentUploadExternalProgram;
let attachmentUploadMimeTypes = [];
@ -325,4 +326,15 @@ if (Meteor.isServer) {
Attachments.getAttachmentWithBackwardCompatibility = getAttachmentWithBackwardCompatibility;
Attachments.getAttachmentsWithBackwardCompatibility = getAttachmentsWithBackwardCompatibility;
// Override the link method to use universal URLs
if (Meteor.isClient) {
// Add custom link method to attachment documents
Attachments.collection.helpers({
link(version = 'original') {
// Use universal URL generator for consistent, URL-agnostic URLs
return generateUniversalAttachmentUrl(this._id, version);
}
});
}
export default Attachments;

View file

@ -8,6 +8,7 @@ import { TAPi18n } from '/imports/i18n';
import fs from 'fs';
import path from 'path';
import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs, STORAGE_NAME_FILESYSTEM } from '/models/lib/fileStoreStrategy';
import { generateUniversalAvatarUrl, cleanFileUrl } from '/models/lib/universalUrlGenerator';
const filesize = require('filesize');
@ -116,7 +117,9 @@ Avatars = new FilesCollection({
const isValid = Promise.await(isFileValid(fileObj, avatarsUploadMimeTypes, avatarsUploadSize, avatarsUploadExternalProgram));
if (isValid) {
ReactiveCache.getUser(fileObj.userId).setAvatarUrl(`${formatFleURL(fileObj)}?auth=false&brokenIsFine=true`);
// Set avatar URL using universal URL generator (URL-agnostic)
const universalUrl = generateUniversalAvatarUrl(fileObj._id);
ReactiveCache.getUser(fileObj.userId).setAvatarUrl(universalUrl);
} else {
Avatars.remove(fileObj._id);
}
@ -164,4 +167,15 @@ if (Meteor.isServer) {
});
}
// Override the link method to use universal URLs
if (Meteor.isClient) {
// Add custom link method to avatar documents
Avatars.collection.helpers({
link(version = 'original') {
// Use universal URL generator for consistent, URL-agnostic URLs
return generateUniversalAvatarUrl(this._id, version);
}
});
}
export default Avatars;

View 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
}