universalFileServer.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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 from '/models/attachments';
  10. import Avatars from '/models/avatars';
  11. import { fileStoreStrategyFactory } from '/models/lib/fileStoreStrategy';
  12. import { getAttachmentWithBackwardCompatibility, getOldAttachmentStream } from '/models/lib/attachmentBackwardCompatibility';
  13. import fs from 'fs';
  14. import path from 'path';
  15. if (Meteor.isServer) {
  16. console.log('Universal file server initializing...');
  17. /**
  18. * Helper function to set appropriate headers for file serving
  19. */
  20. function setFileHeaders(res, fileObj, isAttachment = false) {
  21. // Set content type
  22. res.setHeader('Content-Type', fileObj.type || (isAttachment ? 'application/octet-stream' : 'image/jpeg'));
  23. // Set content length
  24. res.setHeader('Content-Length', fileObj.size || 0);
  25. // Set cache headers
  26. res.setHeader('Cache-Control', 'public, max-age=31536000'); // Cache for 1 year
  27. res.setHeader('ETag', `"${fileObj._id}"`);
  28. // Set security headers for attachments
  29. if (isAttachment) {
  30. const isSvgFile = fileObj.name && fileObj.name.toLowerCase().endsWith('.svg');
  31. const disposition = isSvgFile ? 'attachment' : 'inline';
  32. res.setHeader('Content-Disposition', `${disposition}; filename="${fileObj.name}"`);
  33. // Add security headers for SVG files
  34. if (isSvgFile) {
  35. res.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'none'; object-src 'none';");
  36. res.setHeader('X-Content-Type-Options', 'nosniff');
  37. res.setHeader('X-Frame-Options', 'DENY');
  38. }
  39. }
  40. }
  41. /**
  42. * Helper function to handle conditional requests
  43. */
  44. function handleConditionalRequest(req, res, fileObj) {
  45. const ifNoneMatch = req.headers['if-none-match'];
  46. if (ifNoneMatch && ifNoneMatch === `"${fileObj._id}"`) {
  47. res.writeHead(304);
  48. res.end();
  49. return true;
  50. }
  51. return false;
  52. }
  53. /**
  54. * Helper function to stream file with error handling
  55. */
  56. function streamFile(res, readStream, fileObj) {
  57. readStream.on('error', (error) => {
  58. console.error('File stream error:', error);
  59. if (!res.headersSent) {
  60. res.writeHead(500);
  61. res.end('Error reading file');
  62. }
  63. });
  64. readStream.on('end', () => {
  65. if (!res.headersSent) {
  66. res.writeHead(200);
  67. }
  68. });
  69. readStream.pipe(res);
  70. }
  71. // ============================================================================
  72. // NEW METEOR-FILES ROUTES (URL-agnostic)
  73. // ============================================================================
  74. /**
  75. * Serve attachments from new Meteor-Files structure
  76. * Route: /cdn/storage/attachments/{fileId} or /cdn/storage/attachments/{fileId}/original/{filename}
  77. */
  78. WebApp.connectHandlers.use('/cdn/storage/attachments/([^/]+)(?:/original/[^/]+)?', (req, res, next) => {
  79. if (req.method !== 'GET') {
  80. return next();
  81. }
  82. try {
  83. const fileId = req.params[0];
  84. if (!fileId) {
  85. res.writeHead(400);
  86. res.end('Invalid attachment file ID');
  87. return;
  88. }
  89. // Get attachment from database
  90. const attachment = ReactiveCache.getAttachment(fileId);
  91. if (!attachment) {
  92. res.writeHead(404);
  93. res.end('Attachment not found');
  94. return;
  95. }
  96. // Check permissions
  97. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  98. if (!board) {
  99. res.writeHead(404);
  100. res.end('Board not found');
  101. return;
  102. }
  103. // Check if user has permission to download
  104. const userId = Meteor.userId();
  105. if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
  106. res.writeHead(403);
  107. res.end('Access denied');
  108. return;
  109. }
  110. // Handle conditional requests
  111. if (handleConditionalRequest(req, res, attachment)) {
  112. return;
  113. }
  114. // Get file strategy and stream
  115. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  116. const readStream = strategy.getReadStream();
  117. if (!readStream) {
  118. res.writeHead(404);
  119. res.end('Attachment file not found in storage');
  120. return;
  121. }
  122. // Set headers and stream file
  123. setFileHeaders(res, attachment, true);
  124. streamFile(res, readStream, attachment);
  125. } catch (error) {
  126. console.error('Attachment server error:', error);
  127. if (!res.headersSent) {
  128. res.writeHead(500);
  129. res.end('Internal server error');
  130. }
  131. }
  132. });
  133. /**
  134. * Serve avatars from new Meteor-Files structure
  135. * Route: /cdn/storage/avatars/{fileId} or /cdn/storage/avatars/{fileId}/original/{filename}
  136. */
  137. WebApp.connectHandlers.use('/cdn/storage/avatars/([^/]+)(?:/original/[^/]+)?', (req, res, next) => {
  138. if (req.method !== 'GET') {
  139. return next();
  140. }
  141. try {
  142. const fileId = req.params[0];
  143. if (!fileId) {
  144. res.writeHead(400);
  145. res.end('Invalid avatar file ID');
  146. return;
  147. }
  148. // Get avatar from database
  149. const avatar = ReactiveCache.getAvatar(fileId);
  150. if (!avatar) {
  151. res.writeHead(404);
  152. res.end('Avatar not found');
  153. return;
  154. }
  155. // Check if user has permission to view this avatar
  156. // For avatars, we allow viewing by any logged-in user
  157. const userId = Meteor.userId();
  158. if (!userId) {
  159. res.writeHead(401);
  160. res.end('Authentication required');
  161. return;
  162. }
  163. // Handle conditional requests
  164. if (handleConditionalRequest(req, res, avatar)) {
  165. return;
  166. }
  167. // Get file strategy and stream
  168. const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original');
  169. const readStream = strategy.getReadStream();
  170. if (!readStream) {
  171. res.writeHead(404);
  172. res.end('Avatar file not found in storage');
  173. return;
  174. }
  175. // Set headers and stream file
  176. setFileHeaders(res, avatar, false);
  177. streamFile(res, readStream, avatar);
  178. } catch (error) {
  179. console.error('Avatar server error:', error);
  180. if (!res.headersSent) {
  181. res.writeHead(500);
  182. res.end('Internal server error');
  183. }
  184. }
  185. });
  186. // ============================================================================
  187. // LEGACY COLLECTIONFS ROUTES (Backward compatibility)
  188. // ============================================================================
  189. /**
  190. * Serve legacy attachments from CollectionFS structure
  191. * Route: /cfs/files/attachments/{attachmentId}
  192. */
  193. WebApp.connectHandlers.use('/cfs/files/attachments/([^/]+)', (req, res, next) => {
  194. if (req.method !== 'GET') {
  195. return next();
  196. }
  197. try {
  198. const attachmentId = req.params[0];
  199. if (!attachmentId) {
  200. res.writeHead(400);
  201. res.end('Invalid attachment ID');
  202. return;
  203. }
  204. // Try to get attachment with backward compatibility
  205. const attachment = getAttachmentWithBackwardCompatibility(attachmentId);
  206. if (!attachment) {
  207. res.writeHead(404);
  208. res.end('Attachment not found');
  209. return;
  210. }
  211. // Check permissions
  212. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  213. if (!board) {
  214. res.writeHead(404);
  215. res.end('Board not found');
  216. return;
  217. }
  218. // Check if user has permission to download
  219. const userId = Meteor.userId();
  220. if (!board.isPublic() && (!userId || !board.hasMember(userId))) {
  221. res.writeHead(403);
  222. res.end('Access denied');
  223. return;
  224. }
  225. // Handle conditional requests
  226. if (handleConditionalRequest(req, res, attachment)) {
  227. return;
  228. }
  229. // For legacy attachments, try to get GridFS stream
  230. const fileStream = getOldAttachmentStream(attachmentId);
  231. if (fileStream) {
  232. setFileHeaders(res, attachment, true);
  233. streamFile(res, fileStream, attachment);
  234. } else {
  235. res.writeHead(404);
  236. res.end('Legacy attachment file not found in GridFS');
  237. }
  238. } catch (error) {
  239. console.error('Legacy attachment server error:', error);
  240. if (!res.headersSent) {
  241. res.writeHead(500);
  242. res.end('Internal server error');
  243. }
  244. }
  245. });
  246. /**
  247. * Serve legacy avatars from CollectionFS structure
  248. * Route: /cfs/files/avatars/{avatarId}
  249. */
  250. WebApp.connectHandlers.use('/cfs/files/avatars/([^/]+)', (req, res, next) => {
  251. if (req.method !== 'GET') {
  252. return next();
  253. }
  254. try {
  255. const avatarId = req.params[0];
  256. if (!avatarId) {
  257. res.writeHead(400);
  258. res.end('Invalid avatar ID');
  259. return;
  260. }
  261. // Try to get avatar from database (new structure first)
  262. let avatar = ReactiveCache.getAvatar(avatarId);
  263. // If not found in new structure, try to handle legacy format
  264. if (!avatar) {
  265. // For legacy avatars, we might need to handle different ID formats
  266. // This is a fallback for old CollectionFS avatars
  267. res.writeHead(404);
  268. res.end('Avatar not found');
  269. return;
  270. }
  271. // Check if user has permission to view this avatar
  272. const userId = Meteor.userId();
  273. if (!userId) {
  274. res.writeHead(401);
  275. res.end('Authentication required');
  276. return;
  277. }
  278. // Handle conditional requests
  279. if (handleConditionalRequest(req, res, avatar)) {
  280. return;
  281. }
  282. // Get file strategy and stream
  283. const strategy = fileStoreStrategyFactory.getFileStrategy(avatar, 'original');
  284. const readStream = strategy.getReadStream();
  285. if (!readStream) {
  286. res.writeHead(404);
  287. res.end('Avatar file not found in storage');
  288. return;
  289. }
  290. // Set headers and stream file
  291. setFileHeaders(res, avatar, false);
  292. streamFile(res, readStream, avatar);
  293. } catch (error) {
  294. console.error('Legacy avatar server error:', error);
  295. if (!res.headersSent) {
  296. res.writeHead(500);
  297. res.end('Internal server error');
  298. }
  299. }
  300. });
  301. // ============================================================================
  302. // ALTERNATIVE ROUTES (For different URL patterns)
  303. // ============================================================================
  304. /**
  305. * Alternative attachment route for different URL patterns
  306. * Route: /attachments/{fileId}
  307. */
  308. WebApp.connectHandlers.use('/attachments/([^/]+)', (req, res, next) => {
  309. if (req.method !== 'GET') {
  310. return next();
  311. }
  312. // Redirect to standard route
  313. const fileId = req.params[0];
  314. const newUrl = `/cdn/storage/attachments/${fileId}`;
  315. res.writeHead(301, { 'Location': newUrl });
  316. res.end();
  317. });
  318. /**
  319. * Alternative avatar route for different URL patterns
  320. * Route: /avatars/{fileId}
  321. */
  322. WebApp.connectHandlers.use('/avatars/([^/]+)', (req, res, next) => {
  323. if (req.method !== 'GET') {
  324. return next();
  325. }
  326. // Redirect to standard route
  327. const fileId = req.params[0];
  328. const newUrl = `/cdn/storage/avatars/${fileId}`;
  329. res.writeHead(301, { 'Location': newUrl });
  330. res.end();
  331. });
  332. console.log('Universal file server initialized successfully');
  333. }