wekan/server/routes/universalFileServer.js
Lauri Ojansivu 30620d0ca4
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
Some migrations and mobile fixes.
Thanks to xet7 !
2025-10-25 21:09:07 +03:00

393 lines
11 KiB
JavaScript

/**
* 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');
}