universalFileServer.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. /**
  2. * Universal File Server
  3. * Ensures all attachments and avatars are always visible regardless of ROOT_URL and PORT settings
  4. * Handles both new Meteor-Files and legacy CollectionFS file serving
  5. */
  6. import { Meteor } from 'meteor/meteor';
  7. import { WebApp } from 'meteor/webapp';
  8. import { ReactiveCache } from '/imports/reactiveCache';
  9. import { Accounts } from 'meteor/accounts-base';
  10. import Attachments, { fileStoreStrategyFactory as attachmentStoreFactory } from '/models/attachments';
  11. import Avatars, { fileStoreStrategyFactory as avatarStoreFactory } from '/models/avatars';
  12. import '/models/boards';
  13. import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
  14. import fs from 'fs';
  15. import path from 'path';
  16. if (Meteor.isServer) {
  17. console.log('Universal file server initializing...');
  18. /**
  19. * Helper function to set appropriate headers for file serving
  20. */
  21. function setFileHeaders(res, fileObj, isAttachment = false) {
  22. // Decide safe serving strategy
  23. const nameLower = (fileObj.name || '').toLowerCase();
  24. const typeLower = (fileObj.type || '').toLowerCase();
  25. const isPdfByExt = nameLower.endsWith('.pdf');
  26. // Define dangerous types that must never be served inline
  27. const dangerousTypes = new Set([
  28. 'text/html',
  29. 'application/xhtml+xml',
  30. 'image/svg+xml',
  31. 'text/xml',
  32. 'application/xml',
  33. 'application/javascript',
  34. 'text/javascript'
  35. ]);
  36. // Define safe types that can be served inline for viewing
  37. const safeInlineTypes = new Set([
  38. 'application/pdf',
  39. 'image/jpeg',
  40. 'image/jpg',
  41. 'image/png',
  42. 'image/gif',
  43. 'image/webp',
  44. 'image/avif',
  45. 'image/bmp',
  46. 'video/mp4',
  47. 'video/webm',
  48. 'video/ogg',
  49. 'audio/mpeg',
  50. 'audio/mp3',
  51. 'audio/ogg',
  52. 'audio/wav',
  53. 'audio/webm',
  54. 'text/plain',
  55. 'application/json'
  56. ]);
  57. const isSvg = nameLower.endsWith('.svg') || typeLower === 'image/svg+xml';
  58. const isDangerous = dangerousTypes.has(typeLower) || isSvg;
  59. // Consider PDF safe inline by extension if type is missing/mis-set
  60. const isSafeInline = safeInlineTypes.has(typeLower) || (isAttachment && isPdfByExt);
  61. // Always send strong caching and integrity headers
  62. res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
  63. res.setHeader('ETag', `"${fileObj._id}"`);
  64. res.setHeader('X-Content-Type-Options', 'nosniff');
  65. // Set content length when available
  66. if (fileObj.size) {
  67. res.setHeader('Content-Length', fileObj.size);
  68. }
  69. if (isAttachment) {
  70. // Attachments: dangerous types forced to download, safe types can be inline
  71. if (isDangerous) {
  72. // SECURITY: Force download for dangerous types to prevent XSS
  73. res.setHeader('Content-Type', 'application/octet-stream');
  74. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  75. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  76. res.setHeader('X-Frame-Options', 'DENY');
  77. } else if (isSafeInline) {
  78. // Safe types: serve inline with proper type and restrictive CSP
  79. // If the file is a PDF by extension but type is wrong/missing, correct it
  80. const finalType = (isPdfByExt && typeLower !== 'application/pdf') ? 'application/pdf' : (typeLower || 'application/octet-stream');
  81. res.setHeader('Content-Type', finalType);
  82. res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
  83. // Restrictive CSP for safe types - allow media/img/object for viewer embeds, no scripts
  84. res.setHeader('Content-Security-Policy', "default-src 'none'; object-src 'self'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline';");
  85. } else {
  86. // Unknown types: force download as fallback
  87. res.setHeader('Content-Type', 'application/octet-stream');
  88. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  89. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  90. }
  91. } else {
  92. // Avatars: allow inline images, but never serve SVG inline
  93. if (isSvg || isDangerous) {
  94. // Serve potentially dangerous avatar types as downloads instead
  95. res.setHeader('Content-Type', 'application/octet-stream');
  96. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  97. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  98. res.setHeader('X-Frame-Options', 'DENY');
  99. } else {
  100. // For typical image avatars, use provided type if present, otherwise fall back to a safe generic image type
  101. res.setHeader('Content-Type', typeLower || 'image/jpeg');
  102. res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
  103. }
  104. }
  105. }
  106. /**
  107. * Helper function to handle conditional requests
  108. */
  109. function handleConditionalRequest(req, res, fileObj) {
  110. const ifNoneMatch = req.headers['if-none-match'];
  111. if (ifNoneMatch && ifNoneMatch === `"${fileObj._id}"`) {
  112. res.writeHead(304);
  113. res.end();
  114. return true;
  115. }
  116. return false;
  117. }
  118. /**
  119. * Extract first path segment (file id) from request URL.
  120. * Works whether req.url is the full path or already trimmed by the mount path.
  121. */
  122. function extractFirstIdFromUrl(req, mountPrefix) {
  123. // Strip query string
  124. let urlPath = (req.url || '').split('?')[0];
  125. // If url still contains the mount prefix, remove it
  126. if (mountPrefix && urlPath.startsWith(mountPrefix)) {
  127. urlPath = urlPath.slice(mountPrefix.length);
  128. }
  129. // Ensure leading slash removed for splitting
  130. if (urlPath.startsWith('/')) {
  131. urlPath = urlPath.slice(1);
  132. }
  133. const parts = urlPath.split('/').filter(Boolean);
  134. return parts[0] || null;
  135. }
  136. /**
  137. * Check if the request explicitly asks to download the file
  138. * Recognizes ?download=true or ?download=1 (case-insensitive for key)
  139. */
  140. function isDownloadRequested(req) {
  141. const q = (req.url || '').split('?')[1] || '';
  142. if (!q) return false;
  143. const pairs = q.split('&');
  144. for (const p of pairs) {
  145. const [rawK, rawV] = p.split('=');
  146. const k = decodeURIComponent((rawK || '').trim()).toLowerCase();
  147. const v = decodeURIComponent((rawV || '').trim());
  148. if (k === 'download' && (v === '' || v === 'true' || v === '1')) {
  149. return true;
  150. }
  151. }
  152. return false;
  153. }
  154. /**
  155. * Determine if an avatar request is authorized
  156. * Rules:
  157. * - If a boardId query is provided and that board is public -> allow
  158. * - Else if requester is authenticated (valid token) -> allow
  159. * - Else if avatar's owner belongs to at least one public board -> allow
  160. * - Otherwise -> deny
  161. */
  162. function isAuthorizedForAvatar(req, avatar) {
  163. try {
  164. if (!avatar) return false;
  165. // 1) Check explicit board context via query
  166. const q = parseQuery(req);
  167. const boardId = q.boardId || q.board || q.b;
  168. if (boardId) {
  169. const board = ReactiveCache.getBoard(boardId);
  170. if (board && board.isPublic && board.isPublic()) return true;
  171. // If private board is specified, require membership of requester
  172. const token = extractLoginToken(req);
  173. const user = token ? getUserFromToken(token) : null;
  174. if (user && board && board.hasMember && board.hasMember(user._id)) return true;
  175. return false;
  176. }
  177. // 2) Authenticated request without explicit board context
  178. const token = extractLoginToken(req);
  179. const user = token ? getUserFromToken(token) : null;
  180. if (user) return true;
  181. // 3) Allow if avatar owner is on any public board (so avatars are public only when on public boards)
  182. // Use a lightweight query against Boards
  183. const found = Boards && Boards.findOne({ permission: 'public', 'members.userId': avatar.userId }, { fields: { _id: 1 } });
  184. return !!found;
  185. } catch (e) {
  186. if (process.env.DEBUG === 'true') {
  187. console.warn('Avatar authorization check failed:', e);
  188. }
  189. return false;
  190. }
  191. }
  192. /**
  193. * Parse cookies from request headers into an object map
  194. */
  195. function parseCookies(req) {
  196. const header = req.headers && req.headers.cookie;
  197. const out = {};
  198. if (!header) return out;
  199. const parts = header.split(';');
  200. for (const part of parts) {
  201. const idx = part.indexOf('=');
  202. if (idx === -1) continue;
  203. const k = decodeURIComponent(part.slice(0, idx).trim());
  204. const v = decodeURIComponent(part.slice(idx + 1).trim());
  205. out[k] = v;
  206. }
  207. return out;
  208. }
  209. /**
  210. * Get query parameters as a simple object
  211. */
  212. function parseQuery(req) {
  213. const out = {};
  214. const q = (req.url || '').split('?')[1] || '';
  215. if (!q) return out;
  216. const pairs = q.split('&');
  217. for (const p of pairs) {
  218. if (!p) continue;
  219. const [rawK, rawV] = p.split('=');
  220. const k = decodeURIComponent((rawK || '').trim());
  221. const v = decodeURIComponent((rawV || '').trim());
  222. if (k) out[k] = v;
  223. }
  224. return out;
  225. }
  226. /**
  227. * Extract a login token from Authorization header, query param, or cookie
  228. * Supported sources (priority order):
  229. * - Authorization: Bearer <token>
  230. * - X-Auth-Token header
  231. * - authToken query parameter
  232. * - meteor_login_token or wekan_login_token cookie
  233. */
  234. function extractLoginToken(req) {
  235. // Authorization: Bearer <token>
  236. const authz = req.headers && (req.headers.authorization || req.headers.Authorization);
  237. if (authz && typeof authz === 'string') {
  238. const m = authz.match(/^Bearer\s+(.+)$/i);
  239. if (m && m[1]) return m[1].trim();
  240. }
  241. // X-Auth-Token
  242. const xAuth = req.headers && (req.headers['x-auth-token'] || req.headers['X-Auth-Token']);
  243. if (xAuth && typeof xAuth === 'string') return xAuth.trim();
  244. // Query parameter
  245. const q = parseQuery(req);
  246. if (q.authToken && typeof q.authToken === 'string') return q.authToken.trim();
  247. // Cookies
  248. const cookies = parseCookies(req);
  249. if (cookies.meteor_login_token) return cookies.meteor_login_token.trim();
  250. if (cookies.wekan_login_token) return cookies.wekan_login_token.trim();
  251. return null;
  252. }
  253. /**
  254. * Resolve a user from a raw login token string
  255. */
  256. function getUserFromToken(rawToken) {
  257. try {
  258. if (!rawToken || typeof rawToken !== 'string' || rawToken.length < 10) return null;
  259. const hashed = Accounts._hashLoginToken(rawToken);
  260. return Meteor.users.findOne({ 'services.resume.loginTokens.hashedToken': hashed }, { fields: { _id: 1 } });
  261. } catch (e) {
  262. // In case accounts-base is not available or any error occurs
  263. if (process.env.DEBUG === 'true') {
  264. console.warn('Token resolution error:', e);
  265. }
  266. return null;
  267. }
  268. }
  269. /**
  270. * Authorization helper for board-bound files
  271. * - Public boards: allow
  272. * - Private boards: require valid user who is a member
  273. */
  274. function isAuthorizedForBoard(req, board) {
  275. try {
  276. if (!board) return false;
  277. if (board.isPublic && board.isPublic()) return true;
  278. const token = extractLoginToken(req);
  279. const user = token ? getUserFromToken(token) : null;
  280. return !!(user && board.hasMember && board.hasMember(user._id));
  281. } catch (e) {
  282. if (process.env.DEBUG === 'true') {
  283. console.warn('Authorization check failed:', e);
  284. }
  285. return false;
  286. }
  287. }
  288. /**
  289. * Helper function to stream file with error handling
  290. */
  291. function streamFile(res, readStream, fileObj) {
  292. readStream.on('error', (error) => {
  293. console.error('File stream error:', error);
  294. if (!res.headersSent) {
  295. res.writeHead(500);
  296. res.end('Error reading file');
  297. }
  298. });
  299. readStream.on('end', () => {
  300. if (!res.headersSent) {
  301. res.writeHead(200);
  302. }
  303. });
  304. readStream.pipe(res);
  305. }
  306. // ============================================================================
  307. // NEW METEOR-FILES ROUTES (URL-agnostic)
  308. // ============================================================================
  309. /**
  310. * Serve attachments from new Meteor-Files structure
  311. * Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename}
  312. */
  313. WebApp.connectHandlers.use('/cdn/storage/attachments', (req, res, next) => {
  314. if (req.method !== 'GET') {
  315. return next();
  316. }
  317. try {
  318. const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments');
  319. if (!fileId) {
  320. res.writeHead(400);
  321. res.end('Invalid attachment file ID');
  322. return;
  323. }
  324. // Get attachment from database with backward compatibility
  325. const attachment = getAttachmentWithBackwardCompatibility(fileId);
  326. if (!attachment) {
  327. res.writeHead(404);
  328. res.end('Attachment not found');
  329. return;
  330. }
  331. // Check permissions
  332. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  333. if (!board) {
  334. res.writeHead(404);
  335. res.end('Board not found');
  336. return;
  337. }
  338. // Enforce cookie/header/query-based auth for private boards
  339. if (!isAuthorizedForBoard(req, board)) {
  340. res.writeHead(403);
  341. res.end('Access denied');
  342. return;
  343. }
  344. // Handle conditional requests
  345. if (handleConditionalRequest(req, res, attachment)) {
  346. return;
  347. }
  348. // Choose proper streaming based on source
  349. let readStream;
  350. if (attachment?.meta?.source === 'legacy') {
  351. // Legacy CollectionFS GridFS stream
  352. readStream = getOldAttachmentStream(fileId);
  353. } else {
  354. // New Meteor-Files storage
  355. const strategy = attachmentStoreFactory.getFileStrategy(attachment, 'original');
  356. readStream = strategy.getReadStream();
  357. }
  358. if (!readStream) {
  359. res.writeHead(404);
  360. res.end('Attachment file not found in storage');
  361. return;
  362. }
  363. // Set headers and stream file
  364. if (isDownloadRequested(req)) {
  365. // Force download if requested via query param
  366. res.setHeader('Cache-Control', 'public, max-age=31536000');
  367. res.setHeader('ETag', `"${attachment._id}"`);
  368. if (attachment.size) res.setHeader('Content-Length', attachment.size);
  369. res.setHeader('X-Content-Type-Options', 'nosniff');
  370. res.setHeader('Content-Type', 'application/octet-stream');
  371. res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
  372. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  373. } else {
  374. setFileHeaders(res, attachment, true);
  375. }
  376. streamFile(res, readStream, attachment);
  377. } catch (error) {
  378. console.error('Attachment server error:', error);
  379. if (!res.headersSent) {
  380. res.writeHead(500);
  381. res.end('Internal server error');
  382. }
  383. }
  384. });
  385. /**
  386. * Serve avatars from new Meteor-Files structure
  387. * Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename}
  388. */
  389. WebApp.connectHandlers.use('/cdn/storage/avatars', (req, res, next) => {
  390. if (req.method !== 'GET') {
  391. return next();
  392. }
  393. try {
  394. const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars');
  395. if (!fileId) {
  396. res.writeHead(400);
  397. res.end('Invalid avatar file ID');
  398. return;
  399. }
  400. // Get avatar from database
  401. const avatar = ReactiveCache.getAvatar(fileId);
  402. if (!avatar) {
  403. res.writeHead(404);
  404. res.end('Avatar not found');
  405. return;
  406. }
  407. // Enforce visibility: avatars are public only in the context of public boards
  408. if (!isAuthorizedForAvatar(req, avatar)) {
  409. res.writeHead(403);
  410. res.end('Access denied');
  411. return;
  412. }
  413. // Handle conditional requests
  414. if (handleConditionalRequest(req, res, avatar)) {
  415. return;
  416. }
  417. // Get file strategy and stream
  418. const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
  419. const readStream = strategy.getReadStream();
  420. if (!readStream) {
  421. res.writeHead(404);
  422. res.end('Avatar file not found in storage');
  423. return;
  424. }
  425. // Set headers and stream file
  426. setFileHeaders(res, avatar, false);
  427. streamFile(res, readStream, avatar);
  428. } catch (error) {
  429. console.error('Avatar server error:', error);
  430. if (!res.headersSent) {
  431. res.writeHead(500);
  432. res.end('Internal server error');
  433. }
  434. }
  435. });
  436. // ============================================================================
  437. // LEGACY COLLECTIONFS ROUTES (Backward compatibility)
  438. // ============================================================================
  439. /**
  440. * Serve legacy attachments from CollectionFS structure
  441. * Route: /cfs/files/attachments/{attachmentId}
  442. */
  443. WebApp.connectHandlers.use('/cfs/files/attachments', (req, res, next) => {
  444. if (req.method !== 'GET') {
  445. return next();
  446. }
  447. try {
  448. const attachmentId = extractFirstIdFromUrl(req, '/cfs/files/attachments');
  449. if (!attachmentId) {
  450. res.writeHead(400);
  451. res.end('Invalid attachment ID');
  452. return;
  453. }
  454. // Try to get attachment with backward compatibility
  455. const attachment = getAttachmentWithBackwardCompatibility(attachmentId);
  456. if (!attachment) {
  457. res.writeHead(404);
  458. res.end('Attachment not found');
  459. return;
  460. }
  461. // Check permissions
  462. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  463. if (!board) {
  464. res.writeHead(404);
  465. res.end('Board not found');
  466. return;
  467. }
  468. // Enforce cookie/header/query-based auth for private boards
  469. if (!isAuthorizedForBoard(req, board)) {
  470. res.writeHead(403);
  471. res.end('Access denied');
  472. return;
  473. }
  474. // Handle conditional requests
  475. if (handleConditionalRequest(req, res, attachment)) {
  476. return;
  477. }
  478. // For legacy attachments, try to get GridFS stream
  479. const fileStream = getOldAttachmentStream(attachmentId);
  480. if (fileStream) {
  481. if (isDownloadRequested(req)) {
  482. // Force download if requested
  483. res.setHeader('Cache-Control', 'public, max-age=31536000');
  484. res.setHeader('ETag', `"${attachment._id}"`);
  485. if (attachment.size) res.setHeader('Content-Length', attachment.size);
  486. res.setHeader('X-Content-Type-Options', 'nosniff');
  487. res.setHeader('Content-Type', 'application/octet-stream');
  488. res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
  489. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  490. } else {
  491. setFileHeaders(res, attachment, true);
  492. }
  493. streamFile(res, fileStream, attachment);
  494. } else {
  495. res.writeHead(404);
  496. res.end('Legacy attachment file not found in GridFS');
  497. }
  498. } catch (error) {
  499. console.error('Legacy attachment server error:', error);
  500. if (!res.headersSent) {
  501. res.writeHead(500);
  502. res.end('Internal server error');
  503. }
  504. }
  505. });
  506. /**
  507. * Serve legacy avatars from CollectionFS structure
  508. * Route: /cfs/files/avatars/{avatarId}
  509. */
  510. WebApp.connectHandlers.use('/cfs/files/avatars', (req, res, next) => {
  511. if (req.method !== 'GET') {
  512. return next();
  513. }
  514. try {
  515. const avatarId = extractFirstIdFromUrl(req, '/cfs/files/avatars');
  516. if (!avatarId) {
  517. res.writeHead(400);
  518. res.end('Invalid avatar ID');
  519. return;
  520. }
  521. // Try to get avatar from database (new structure first)
  522. let avatar = ReactiveCache.getAvatar(avatarId);
  523. // If not found in new structure, try to handle legacy format
  524. if (!avatar) {
  525. // For legacy avatars, we might need to handle different ID formats
  526. // This is a fallback for old CollectionFS avatars
  527. res.writeHead(404);
  528. res.end('Avatar not found');
  529. return;
  530. }
  531. // Enforce visibility for legacy avatars as well
  532. if (!isAuthorizedForAvatar(req, avatar)) {
  533. res.writeHead(403);
  534. res.end('Access denied');
  535. return;
  536. }
  537. // Handle conditional requests
  538. if (handleConditionalRequest(req, res, avatar)) {
  539. return;
  540. }
  541. // Get file strategy and stream
  542. const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
  543. const readStream = strategy.getReadStream();
  544. if (!readStream) {
  545. res.writeHead(404);
  546. res.end('Avatar file not found in storage');
  547. return;
  548. }
  549. // Set headers and stream file
  550. setFileHeaders(res, avatar, false);
  551. streamFile(res, readStream, avatar);
  552. } catch (error) {
  553. console.error('Legacy avatar server error:', error);
  554. if (!res.headersSent) {
  555. res.writeHead(500);
  556. res.end('Internal server error');
  557. }
  558. }
  559. });
  560. // ============================================================================
  561. // ALTERNATIVE ROUTES (For different URL patterns)
  562. // ============================================================================
  563. /**
  564. * Alternative attachment route for different URL patterns
  565. * Route: /attachments/{fileId}
  566. */
  567. WebApp.connectHandlers.use('/attachments', (req, res, next) => {
  568. if (req.method !== 'GET') {
  569. return next();
  570. }
  571. // Redirect to standard route
  572. const fileId = extractFirstIdFromUrl(req, '/attachments');
  573. const newUrl = `/cdn/storage/attachments/${fileId}`;
  574. res.writeHead(301, { 'Location': newUrl });
  575. res.end();
  576. });
  577. /**
  578. * Alternative avatar route for different URL patterns
  579. * Route: /avatars/{fileId}
  580. */
  581. WebApp.connectHandlers.use('/avatars', (req, res, next) => {
  582. if (req.method !== 'GET') {
  583. return next();
  584. }
  585. // Redirect to standard route
  586. const fileId = extractFirstIdFromUrl(req, '/avatars');
  587. const newUrl = `/cdn/storage/avatars/${fileId}`;
  588. res.writeHead(301, { 'Location': newUrl });
  589. res.end();
  590. });
  591. console.log('Universal file server initialized successfully');
  592. }