/** * 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 { Accounts } from 'meteor/accounts-base'; import Attachments, { fileStoreStrategyFactory as attachmentStoreFactory } from '/models/attachments'; import Avatars, { fileStoreStrategyFactory as avatarStoreFactory } from '/models/avatars'; import '/models/boards'; 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) { // Decide safe serving strategy const nameLower = (fileObj.name || '').toLowerCase(); const typeLower = (fileObj.type || '').toLowerCase(); const isPdfByExt = nameLower.endsWith('.pdf'); // Define dangerous types that must never be served inline const dangerousTypes = new Set([ 'text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml', 'application/javascript', 'text/javascript' ]); // Define safe types that can be served inline for viewing const safeInlineTypes = new Set([ 'application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'video/mp4', 'video/webm', 'video/ogg', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav', 'audio/webm', 'text/plain', 'application/json' ]); const isSvg = nameLower.endsWith('.svg') || typeLower === 'image/svg+xml'; const isDangerous = dangerousTypes.has(typeLower) || isSvg; // Consider PDF safe inline by extension if type is missing/mis-set const isSafeInline = safeInlineTypes.has(typeLower) || (isAttachment && isPdfByExt); // Always send strong caching and integrity headers res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year res.setHeader('ETag', `"${fileObj._id}"`); res.setHeader('X-Content-Type-Options', 'nosniff'); // Set content length when available if (fileObj.size) { res.setHeader('Content-Length', fileObj.size); } if (isAttachment) { // Attachments: dangerous types forced to download, safe types can be inline if (isDangerous) { // SECURITY: Force download for dangerous types to prevent XSS res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;"); res.setHeader('X-Frame-Options', 'DENY'); } else if (isSafeInline) { // Safe types: serve inline with proper type and restrictive CSP // If the file is a PDF by extension but type is wrong/missing, correct it const finalType = (isPdfByExt && typeLower !== 'application/pdf') ? 'application/pdf' : (typeLower || 'application/octet-stream'); res.setHeader('Content-Type', finalType); res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`); // Restrictive CSP for safe types - allow media/img/object for viewer embeds, no scripts res.setHeader('Content-Security-Policy', "default-src 'none'; object-src 'self'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline';"); } else { // Unknown types: force download as fallback res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;"); } } else { // Avatars: allow inline images, but never serve SVG inline if (isSvg || isDangerous) { // Serve potentially dangerous avatar types as downloads instead res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;"); res.setHeader('X-Frame-Options', 'DENY'); } else { // For typical image avatars, use provided type if present, otherwise fall back to a safe generic image type res.setHeader('Content-Type', typeLower || 'image/jpeg'); res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`); } } } /** * 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; } /** * Extract first path segment (file id) from request URL. * Works whether req.url is the full path or already trimmed by the mount path. */ function extractFirstIdFromUrl(req, mountPrefix) { // Strip query string let urlPath = (req.url || '').split('?')[0]; // If url still contains the mount prefix, remove it if (mountPrefix && urlPath.startsWith(mountPrefix)) { urlPath = urlPath.slice(mountPrefix.length); } // Ensure leading slash removed for splitting if (urlPath.startsWith('/')) { urlPath = urlPath.slice(1); } const parts = urlPath.split('/').filter(Boolean); return parts[0] || null; } /** * Check if the request explicitly asks to download the file * Recognizes ?download=true or ?download=1 (case-insensitive for key) */ function isDownloadRequested(req) { const q = (req.url || '').split('?')[1] || ''; if (!q) return false; const pairs = q.split('&'); for (const p of pairs) { const [rawK, rawV] = p.split('='); const k = decodeURIComponent((rawK || '').trim()).toLowerCase(); const v = decodeURIComponent((rawV || '').trim()); if (k === 'download' && (v === '' || v === 'true' || v === '1')) { return true; } } return false; } /** * Determine if an avatar request is authorized * Rules: * - If a boardId query is provided and that board is public -> allow * - Else if requester is authenticated (valid token) -> allow * - Else if avatar's owner belongs to at least one public board -> allow * - Otherwise -> deny */ function isAuthorizedForAvatar(req, avatar) { try { if (!avatar) return false; // 1) Check explicit board context via query const q = parseQuery(req); const boardId = q.boardId || q.board || q.b; if (boardId) { const board = ReactiveCache.getBoard(boardId); if (board && board.isPublic && board.isPublic()) return true; // If private board is specified, require membership of requester const token = extractLoginToken(req); const user = token ? getUserFromToken(token) : null; if (user && board && board.hasMember && board.hasMember(user._id)) return true; return false; } // 2) Authenticated request without explicit board context const token = extractLoginToken(req); const user = token ? getUserFromToken(token) : null; if (user) return true; // 3) Allow if avatar owner is on any public board (so avatars are public only when on public boards) // Use a lightweight query against Boards const found = Boards && Boards.findOne({ permission: 'public', 'members.userId': avatar.userId }, { fields: { _id: 1 } }); return !!found; } catch (e) { if (process.env.DEBUG === 'true') { console.warn('Avatar authorization check failed:', e); } return false; } } /** * Parse cookies from request headers into an object map */ function parseCookies(req) { const header = req.headers && req.headers.cookie; const out = {}; if (!header) return out; const parts = header.split(';'); for (const part of parts) { const idx = part.indexOf('='); if (idx === -1) continue; const k = decodeURIComponent(part.slice(0, idx).trim()); const v = decodeURIComponent(part.slice(idx + 1).trim()); out[k] = v; } return out; } /** * Get query parameters as a simple object */ function parseQuery(req) { const out = {}; const q = (req.url || '').split('?')[1] || ''; if (!q) return out; const pairs = q.split('&'); for (const p of pairs) { if (!p) continue; const [rawK, rawV] = p.split('='); const k = decodeURIComponent((rawK || '').trim()); const v = decodeURIComponent((rawV || '').trim()); if (k) out[k] = v; } return out; } /** * Extract a login token from Authorization header, query param, or cookie * Supported sources (priority order): * - Authorization: Bearer * - X-Auth-Token header * - authToken query parameter * - meteor_login_token or wekan_login_token cookie */ function extractLoginToken(req) { // Authorization: Bearer const authz = req.headers && (req.headers.authorization || req.headers.Authorization); if (authz && typeof authz === 'string') { const m = authz.match(/^Bearer\s+(.+)$/i); if (m && m[1]) return m[1].trim(); } // X-Auth-Token const xAuth = req.headers && (req.headers['x-auth-token'] || req.headers['X-Auth-Token']); if (xAuth && typeof xAuth === 'string') return xAuth.trim(); // Query parameter const q = parseQuery(req); if (q.authToken && typeof q.authToken === 'string') return q.authToken.trim(); // Cookies const cookies = parseCookies(req); if (cookies.meteor_login_token) return cookies.meteor_login_token.trim(); if (cookies.wekan_login_token) return cookies.wekan_login_token.trim(); return null; } /** * Resolve a user from a raw login token string */ function getUserFromToken(rawToken) { try { if (!rawToken || typeof rawToken !== 'string' || rawToken.length < 10) return null; const hashed = Accounts._hashLoginToken(rawToken); return Meteor.users.findOne({ 'services.resume.loginTokens.hashedToken': hashed }, { fields: { _id: 1 } }); } catch (e) { // In case accounts-base is not available or any error occurs if (process.env.DEBUG === 'true') { console.warn('Token resolution error:', e); } return null; } } /** * Authorization helper for board-bound files * - Public boards: allow * - Private boards: require valid user who is a member */ function isAuthorizedForBoard(req, board) { try { if (!board) return false; if (board.isPublic && board.isPublic()) return true; const token = extractLoginToken(req); const user = token ? getUserFromToken(token) : null; return !!(user && board.hasMember && board.hasMember(user._id)); } catch (e) { if (process.env.DEBUG === 'true') { console.warn('Authorization check failed:', e); } 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', (req, res, next) => { if (req.method !== 'GET') { return next(); } try { const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments'); if (!fileId) { res.writeHead(400); res.end('Invalid attachment file ID'); return; } // Get attachment from database with backward compatibility const attachment = getAttachmentWithBackwardCompatibility(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; } // Enforce cookie/header/query-based auth for private boards if (!isAuthorizedForBoard(req, board)) { res.writeHead(403); res.end('Access denied'); return; } // Handle conditional requests if (handleConditionalRequest(req, res, attachment)) { return; } // Choose proper streaming based on source let readStream; if (attachment?.meta?.source === 'legacy') { // Legacy CollectionFS GridFS stream readStream = getOldAttachmentStream(fileId); } else { // New Meteor-Files storage const strategy = attachmentStoreFactory.getFileStrategy(attachment, 'original'); readStream = strategy.getReadStream(); } if (!readStream) { res.writeHead(404); res.end('Attachment file not found in storage'); return; } // Set headers and stream file if (isDownloadRequested(req)) { // Force download if requested via query param res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('ETag', `"${attachment._id}"`); if (attachment.size) res.setHeader('Content-Length', attachment.size); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;"); } else { 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', (req, res, next) => { if (req.method !== 'GET') { return next(); } try { const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars'); 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; } // Enforce visibility: avatars are public only in the context of public boards if (!isAuthorizedForAvatar(req, avatar)) { res.writeHead(403); res.end('Access denied'); return; } // Handle conditional requests if (handleConditionalRequest(req, res, avatar)) { return; } // Get file strategy and stream const strategy = avatarStoreFactory.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 = extractFirstIdFromUrl(req, '/cfs/files/attachments'); 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; } // Enforce cookie/header/query-based auth for private boards if (!isAuthorizedForBoard(req, board)) { 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) { if (isDownloadRequested(req)) { // Force download if requested res.setHeader('Cache-Control', 'public, max-age=31536000'); res.setHeader('ETag', `"${attachment._id}"`); if (attachment.size) res.setHeader('Content-Length', attachment.size); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`); res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;"); } else { 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 = extractFirstIdFromUrl(req, '/cfs/files/avatars'); 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; } // Enforce visibility for legacy avatars as well if (!isAuthorizedForAvatar(req, avatar)) { res.writeHead(403); res.end('Access denied'); return; } // Handle conditional requests if (handleConditionalRequest(req, res, avatar)) { return; } // Get file strategy and stream const strategy = avatarStoreFactory.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 = extractFirstIdFromUrl(req, '/attachments'); 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 = extractFirstIdFromUrl(req, '/avatars'); const newUrl = `/cdn/storage/avatars/${fileId}`; res.writeHead(301, { 'Location': newUrl }); res.end(); }); console.log('Universal file server initialized successfully'); }