universalFileServer.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  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 Attachments, { fileStoreStrategyFactory as attachmentStoreFactory } from '/models/attachments';
  10. import Avatars, { fileStoreStrategyFactory as avatarStoreFactory } from '/models/avatars';
  11. import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
  12. import fs from 'fs';
  13. import path from 'path';
  14. if (Meteor.isServer) {
  15. console.log('Universal file server initializing...');
  16. /**
  17. * Helper function to set appropriate headers for file serving
  18. */
  19. function setFileHeaders(res, fileObj, isAttachment = false) {
  20. // Decide safe serving strategy
  21. const nameLower = (fileObj.name || '').toLowerCase();
  22. const typeLower = (fileObj.type || '').toLowerCase();
  23. const isPdfByExt = nameLower.endsWith('.pdf');
  24. // Define dangerous types that must never be served inline
  25. const dangerousTypes = new Set([
  26. 'text/html',
  27. 'application/xhtml+xml',
  28. 'image/svg+xml',
  29. 'text/xml',
  30. 'application/xml',
  31. 'application/javascript',
  32. 'text/javascript'
  33. ]);
  34. // Define safe types that can be served inline for viewing
  35. const safeInlineTypes = new Set([
  36. 'application/pdf',
  37. 'image/jpeg',
  38. 'image/jpg',
  39. 'image/png',
  40. 'image/gif',
  41. 'image/webp',
  42. 'image/avif',
  43. 'image/bmp',
  44. 'video/mp4',
  45. 'video/webm',
  46. 'video/ogg',
  47. 'audio/mpeg',
  48. 'audio/mp3',
  49. 'audio/ogg',
  50. 'audio/wav',
  51. 'audio/webm',
  52. 'text/plain',
  53. 'application/json'
  54. ]);
  55. const isSvg = nameLower.endsWith('.svg') || typeLower === 'image/svg+xml';
  56. const isDangerous = dangerousTypes.has(typeLower) || isSvg;
  57. // Consider PDF safe inline by extension if type is missing/mis-set
  58. const isSafeInline = safeInlineTypes.has(typeLower) || (isAttachment && isPdfByExt);
  59. // Always send strong caching and integrity headers
  60. res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
  61. res.setHeader('ETag', `"${fileObj._id}"`);
  62. res.setHeader('X-Content-Type-Options', 'nosniff');
  63. // Set content length when available
  64. if (fileObj.size) {
  65. res.setHeader('Content-Length', fileObj.size);
  66. }
  67. if (isAttachment) {
  68. // Attachments: dangerous types forced to download, safe types can be inline
  69. if (isDangerous) {
  70. // SECURITY: Force download for dangerous types to prevent XSS
  71. res.setHeader('Content-Type', 'application/octet-stream');
  72. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  73. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  74. res.setHeader('X-Frame-Options', 'DENY');
  75. } else if (isSafeInline) {
  76. // Safe types: serve inline with proper type and restrictive CSP
  77. // If the file is a PDF by extension but type is wrong/missing, correct it
  78. const finalType = (isPdfByExt && typeLower !== 'application/pdf') ? 'application/pdf' : (typeLower || 'application/octet-stream');
  79. res.setHeader('Content-Type', finalType);
  80. res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
  81. // Restrictive CSP for safe types - allow media/img/object for viewer embeds, no scripts
  82. res.setHeader('Content-Security-Policy', "default-src 'none'; object-src 'self'; media-src 'self'; img-src 'self'; style-src 'unsafe-inline';");
  83. } else {
  84. // Unknown types: force download as fallback
  85. res.setHeader('Content-Type', 'application/octet-stream');
  86. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  87. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  88. }
  89. } else {
  90. // Avatars: allow inline images, but never serve SVG inline
  91. if (isSvg || isDangerous) {
  92. // Serve potentially dangerous avatar types as downloads instead
  93. res.setHeader('Content-Type', 'application/octet-stream');
  94. res.setHeader('Content-Disposition', `attachment; filename="${fileObj.name}"`);
  95. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  96. res.setHeader('X-Frame-Options', 'DENY');
  97. } else {
  98. // For typical image avatars, use provided type if present, otherwise fall back to a safe generic image type
  99. res.setHeader('Content-Type', typeLower || 'image/jpeg');
  100. res.setHeader('Content-Disposition', `inline; filename="${fileObj.name}"`);
  101. }
  102. }
  103. }
  104. /**
  105. * Helper function to handle conditional requests
  106. */
  107. function handleConditionalRequest(req, res, fileObj) {
  108. const ifNoneMatch = req.headers['if-none-match'];
  109. if (ifNoneMatch && ifNoneMatch === `"${fileObj._id}"`) {
  110. res.writeHead(304);
  111. res.end();
  112. return true;
  113. }
  114. return false;
  115. }
  116. /**
  117. * Extract first path segment (file id) from request URL.
  118. * Works whether req.url is the full path or already trimmed by the mount path.
  119. */
  120. function extractFirstIdFromUrl(req, mountPrefix) {
  121. // Strip query string
  122. let urlPath = (req.url || '').split('?')[0];
  123. // If url still contains the mount prefix, remove it
  124. if (mountPrefix && urlPath.startsWith(mountPrefix)) {
  125. urlPath = urlPath.slice(mountPrefix.length);
  126. }
  127. // Ensure leading slash removed for splitting
  128. if (urlPath.startsWith('/')) {
  129. urlPath = urlPath.slice(1);
  130. }
  131. const parts = urlPath.split('/').filter(Boolean);
  132. return parts[0] || null;
  133. }
  134. /**
  135. * Check if the request explicitly asks to download the file
  136. * Recognizes ?download=true or ?download=1 (case-insensitive for key)
  137. */
  138. function isDownloadRequested(req) {
  139. const q = (req.url || '').split('?')[1] || '';
  140. if (!q) return false;
  141. const pairs = q.split('&');
  142. for (const p of pairs) {
  143. const [rawK, rawV] = p.split('=');
  144. const k = decodeURIComponent((rawK || '').trim()).toLowerCase();
  145. const v = decodeURIComponent((rawV || '').trim());
  146. if (k === 'download' && (v === '' || v === 'true' || v === '1')) {
  147. return true;
  148. }
  149. }
  150. return false;
  151. }
  152. /**
  153. * Helper function to stream file with error handling
  154. */
  155. function streamFile(res, readStream, fileObj) {
  156. readStream.on('error', (error) => {
  157. console.error('File stream error:', error);
  158. if (!res.headersSent) {
  159. res.writeHead(500);
  160. res.end('Error reading file');
  161. }
  162. });
  163. readStream.on('end', () => {
  164. if (!res.headersSent) {
  165. res.writeHead(200);
  166. }
  167. });
  168. readStream.pipe(res);
  169. }
  170. // ============================================================================
  171. // NEW METEOR-FILES ROUTES (URL-agnostic)
  172. // ============================================================================
  173. /**
  174. * Serve attachments from new Meteor-Files structure
  175. * Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename}
  176. */
  177. WebApp.connectHandlers.use('/cdn/storage/attachments', (req, res, next) => {
  178. if (req.method !== 'GET') {
  179. return next();
  180. }
  181. try {
  182. const fileId = extractFirstIdFromUrl(req, '/cdn/storage/attachments');
  183. if (!fileId) {
  184. res.writeHead(400);
  185. res.end('Invalid attachment file ID');
  186. return;
  187. }
  188. // Get attachment from database
  189. const attachment = ReactiveCache.getAttachment(fileId);
  190. if (!attachment) {
  191. res.writeHead(404);
  192. res.end('Attachment not found');
  193. return;
  194. }
  195. // Check permissions
  196. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  197. if (!board) {
  198. res.writeHead(404);
  199. res.end('Board not found');
  200. return;
  201. }
  202. // TODO: Implement proper authentication via cookies/headers
  203. // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
  204. // For now, allow access - ostrio:files protected() method provides fallback auth
  205. // const userId = null; // Need to extract from req.headers.cookie
  206. // if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
  207. // res.writeHead(403);
  208. // res.end('Access denied');
  209. // return;
  210. // }
  211. // Handle conditional requests
  212. if (handleConditionalRequest(req, res, attachment)) {
  213. return;
  214. }
  215. // Get file strategy and stream
  216. const strategy = attachmentStoreFactory.getFileStrategy(attachment, 'original');
  217. const readStream = strategy.getReadStream();
  218. if (!readStream) {
  219. res.writeHead(404);
  220. res.end('Attachment file not found in storage');
  221. return;
  222. }
  223. // Set headers and stream file
  224. if (isDownloadRequested(req)) {
  225. // Force download if requested via query param
  226. res.setHeader('Cache-Control', 'public, max-age=31536000');
  227. res.setHeader('ETag', `"${attachment._id}"`);
  228. if (attachment.size) res.setHeader('Content-Length', attachment.size);
  229. res.setHeader('X-Content-Type-Options', 'nosniff');
  230. res.setHeader('Content-Type', 'application/octet-stream');
  231. res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
  232. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  233. } else {
  234. setFileHeaders(res, attachment, true);
  235. }
  236. streamFile(res, readStream, attachment);
  237. } catch (error) {
  238. console.error('Attachment server error:', error);
  239. if (!res.headersSent) {
  240. res.writeHead(500);
  241. res.end('Internal server error');
  242. }
  243. }
  244. });
  245. /**
  246. * Serve avatars from new Meteor-Files structure
  247. * Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename}
  248. */
  249. WebApp.connectHandlers.use('/cdn/storage/avatars', (req, res, next) => {
  250. if (req.method !== 'GET') {
  251. return next();
  252. }
  253. try {
  254. const fileId = extractFirstIdFromUrl(req, '/cdn/storage/avatars');
  255. if (!fileId) {
  256. res.writeHead(400);
  257. res.end('Invalid avatar file ID');
  258. return;
  259. }
  260. // Get avatar from database
  261. const avatar = ReactiveCache.getAvatar(fileId);
  262. if (!avatar) {
  263. res.writeHead(404);
  264. res.end('Avatar not found');
  265. return;
  266. }
  267. // TODO: Implement proper authentication for avatars
  268. // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
  269. // For now, allow avatar viewing - they're typically public anyway
  270. // Handle conditional requests
  271. if (handleConditionalRequest(req, res, avatar)) {
  272. return;
  273. }
  274. // Get file strategy and stream
  275. const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
  276. const readStream = strategy.getReadStream();
  277. if (!readStream) {
  278. res.writeHead(404);
  279. res.end('Avatar file not found in storage');
  280. return;
  281. }
  282. // Set headers and stream file
  283. setFileHeaders(res, avatar, false);
  284. streamFile(res, readStream, avatar);
  285. } catch (error) {
  286. console.error('Avatar server error:', error);
  287. if (!res.headersSent) {
  288. res.writeHead(500);
  289. res.end('Internal server error');
  290. }
  291. }
  292. });
  293. // ============================================================================
  294. // LEGACY COLLECTIONFS ROUTES (Backward compatibility)
  295. // ============================================================================
  296. /**
  297. * Serve legacy attachments from CollectionFS structure
  298. * Route: /cfs/files/attachments/{attachmentId}
  299. */
  300. WebApp.connectHandlers.use('/cfs/files/attachments', (req, res, next) => {
  301. if (req.method !== 'GET') {
  302. return next();
  303. }
  304. try {
  305. const attachmentId = extractFirstIdFromUrl(req, '/cfs/files/attachments');
  306. if (!attachmentId) {
  307. res.writeHead(400);
  308. res.end('Invalid attachment ID');
  309. return;
  310. }
  311. // Try to get attachment with backward compatibility
  312. const attachment = getAttachmentWithBackwardCompatibility(attachmentId);
  313. if (!attachment) {
  314. res.writeHead(404);
  315. res.end('Attachment not found');
  316. return;
  317. }
  318. // Check permissions
  319. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  320. if (!board) {
  321. res.writeHead(404);
  322. res.end('Board not found');
  323. return;
  324. }
  325. // TODO: Implement proper authentication via cookies/headers
  326. // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
  327. // For now, allow access for compatibility
  328. // Handle conditional requests
  329. if (handleConditionalRequest(req, res, attachment)) {
  330. return;
  331. }
  332. // For legacy attachments, try to get GridFS stream
  333. const fileStream = getOldAttachmentStream(attachmentId);
  334. if (fileStream) {
  335. if (isDownloadRequested(req)) {
  336. // Force download if requested
  337. res.setHeader('Cache-Control', 'public, max-age=31536000');
  338. res.setHeader('ETag', `"${attachment._id}"`);
  339. if (attachment.size) res.setHeader('Content-Length', attachment.size);
  340. res.setHeader('X-Content-Type-Options', 'nosniff');
  341. res.setHeader('Content-Type', 'application/octet-stream');
  342. res.setHeader('Content-Disposition', `attachment; filename="${attachment.name}"`);
  343. res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox;");
  344. } else {
  345. setFileHeaders(res, attachment, true);
  346. }
  347. streamFile(res, fileStream, attachment);
  348. } else {
  349. res.writeHead(404);
  350. res.end('Legacy attachment file not found in GridFS');
  351. }
  352. } catch (error) {
  353. console.error('Legacy attachment server error:', error);
  354. if (!res.headersSent) {
  355. res.writeHead(500);
  356. res.end('Internal server error');
  357. }
  358. }
  359. });
  360. /**
  361. * Serve legacy avatars from CollectionFS structure
  362. * Route: /cfs/files/avatars/{avatarId}
  363. */
  364. WebApp.connectHandlers.use('/cfs/files/avatars', (req, res, next) => {
  365. if (req.method !== 'GET') {
  366. return next();
  367. }
  368. try {
  369. const avatarId = extractFirstIdFromUrl(req, '/cfs/files/avatars');
  370. if (!avatarId) {
  371. res.writeHead(400);
  372. res.end('Invalid avatar ID');
  373. return;
  374. }
  375. // Try to get avatar from database (new structure first)
  376. let avatar = ReactiveCache.getAvatar(avatarId);
  377. // If not found in new structure, try to handle legacy format
  378. if (!avatar) {
  379. // For legacy avatars, we might need to handle different ID formats
  380. // This is a fallback for old CollectionFS avatars
  381. res.writeHead(404);
  382. res.end('Avatar not found');
  383. return;
  384. }
  385. // TODO: Implement proper authentication for legacy avatars
  386. // Meteor.userId() returns undefined in WebApp.connectHandlers middleware
  387. // For now, allow avatar viewing for compatibility
  388. // Handle conditional requests
  389. if (handleConditionalRequest(req, res, avatar)) {
  390. return;
  391. }
  392. // Get file strategy and stream
  393. const strategy = avatarStoreFactory.getFileStrategy(avatar, 'original');
  394. const readStream = strategy.getReadStream();
  395. if (!readStream) {
  396. res.writeHead(404);
  397. res.end('Avatar file not found in storage');
  398. return;
  399. }
  400. // Set headers and stream file
  401. setFileHeaders(res, avatar, false);
  402. streamFile(res, readStream, avatar);
  403. } catch (error) {
  404. console.error('Legacy avatar server error:', error);
  405. if (!res.headersSent) {
  406. res.writeHead(500);
  407. res.end('Internal server error');
  408. }
  409. }
  410. });
  411. // ============================================================================
  412. // ALTERNATIVE ROUTES (For different URL patterns)
  413. // ============================================================================
  414. /**
  415. * Alternative attachment route for different URL patterns
  416. * Route: /attachments/{fileId}
  417. */
  418. WebApp.connectHandlers.use('/attachments', (req, res, next) => {
  419. if (req.method !== 'GET') {
  420. return next();
  421. }
  422. // Redirect to standard route
  423. const fileId = extractFirstIdFromUrl(req, '/attachments');
  424. const newUrl = `/cdn/storage/attachments/${fileId}`;
  425. res.writeHead(301, { 'Location': newUrl });
  426. res.end();
  427. });
  428. /**
  429. * Alternative avatar route for different URL patterns
  430. * Route: /avatars/{fileId}
  431. */
  432. WebApp.connectHandlers.use('/avatars', (req, res, next) => {
  433. if (req.method !== 'GET') {
  434. return next();
  435. }
  436. // Redirect to standard route
  437. const fileId = extractFirstIdFromUrl(req, '/avatars');
  438. const newUrl = `/cdn/storage/avatars/${fileId}`;
  439. res.writeHead(301, { 'Location': newUrl });
  440. res.end();
  441. });
  442. console.log('Universal file server initialized successfully');
  443. }