mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30:13 +01:00
670 lines
22 KiB
JavaScript
670 lines
22 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 { 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 <token>
|
|
* - X-Auth-Token header
|
|
* - authToken query parameter
|
|
* - meteor_login_token or wekan_login_token cookie
|
|
*/
|
|
function extractLoginToken(req) {
|
|
// Authorization: Bearer <token>
|
|
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');
|
|
}
|