attachmentApi.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
  1. import { Meteor } from 'meteor/meteor';
  2. import { Accounts } from 'meteor/accounts-base';
  3. import { WebApp } from 'meteor/webapp';
  4. import { ReactiveCache } from '/imports/reactiveCache';
  5. import { Attachments, fileStoreStrategyFactory } from '/models/attachments';
  6. import { moveToStorage } from '/models/lib/fileStoreStrategy';
  7. import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
  8. import AttachmentStorageSettings from '/models/attachmentStorageSettings';
  9. import fs from 'fs';
  10. import path from 'path';
  11. import { ObjectID } from 'bson';
  12. // Attachment API HTTP routes
  13. if (Meteor.isServer) {
  14. // Helper function to authenticate API requests using X-User-Id and X-Auth-Token
  15. function authenticateApiRequest(req) {
  16. const userId = req.headers['x-user-id'];
  17. const authToken = req.headers['x-auth-token'];
  18. if (!userId || !authToken) {
  19. throw new Meteor.Error('unauthorized', 'Missing X-User-Id or X-Auth-Token headers');
  20. }
  21. // Hash the token and validate against stored login tokens
  22. const hashedToken = Accounts._hashLoginToken(authToken);
  23. const user = Meteor.users.findOne({
  24. _id: userId,
  25. 'services.resume.loginTokens.hashedToken': hashedToken,
  26. });
  27. if (!user) {
  28. throw new Meteor.Error('unauthorized', 'Invalid credentials');
  29. }
  30. return userId;
  31. }
  32. // Helper function to send JSON response
  33. function sendJsonResponse(res, statusCode, data) {
  34. res.writeHead(statusCode, { 'Content-Type': 'application/json' });
  35. res.end(JSON.stringify(data));
  36. }
  37. // Helper function to send error response
  38. function sendErrorResponse(res, statusCode, message) {
  39. sendJsonResponse(res, statusCode, { success: false, error: message });
  40. }
  41. // Upload attachment endpoint
  42. WebApp.connectHandlers.use('/api/attachment/upload', (req, res, next) => {
  43. if (req.method !== 'POST') {
  44. return next();
  45. }
  46. // Set timeout to prevent hanging connections
  47. const timeout = setTimeout(() => {
  48. if (!res.headersSent) {
  49. sendErrorResponse(res, 408, 'Request timeout');
  50. }
  51. }, 30000); // 30 second timeout
  52. try {
  53. const userId = authenticateApiRequest(req);
  54. let body = '';
  55. let bodyComplete = false;
  56. req.on('data', chunk => {
  57. body += chunk.toString();
  58. // Prevent excessive payload
  59. if (body.length > 50 * 1024 * 1024) { // 50MB limit
  60. req.connection.destroy();
  61. clearTimeout(timeout);
  62. }
  63. });
  64. req.on('end', () => {
  65. if (bodyComplete) return; // Already processed
  66. bodyComplete = true;
  67. clearTimeout(timeout);
  68. try {
  69. const data = JSON.parse(body);
  70. const { boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend } = data;
  71. // Validate parameters
  72. if (!boardId || !swimlaneId || !listId || !cardId || !fileData || !fileName) {
  73. return sendErrorResponse(res, 400, 'Missing required parameters');
  74. }
  75. // Check if user has permission to modify the card
  76. const card = ReactiveCache.getCard(cardId);
  77. if (!card) {
  78. return sendErrorResponse(res, 404, 'Card not found');
  79. }
  80. const board = ReactiveCache.getBoard(boardId);
  81. if (!board) {
  82. return sendErrorResponse(res, 404, 'Board not found');
  83. }
  84. // Check permissions
  85. if (!board.isBoardMember(userId)) {
  86. return sendErrorResponse(res, 403, 'You do not have permission to modify this card');
  87. }
  88. // Check if board allows attachments
  89. if (!board.allowsAttachments) {
  90. return sendErrorResponse(res, 403, 'Attachments are not allowed on this board');
  91. }
  92. // Get default storage backend if not specified
  93. let targetStorage = storageBackend;
  94. if (!targetStorage) {
  95. try {
  96. const settings = AttachmentStorageSettings.findOne({});
  97. targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
  98. } catch (error) {
  99. targetStorage = STORAGE_NAME_FILESYSTEM;
  100. }
  101. }
  102. // Validate storage backend
  103. if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(targetStorage)) {
  104. return sendErrorResponse(res, 400, 'Invalid storage backend');
  105. }
  106. // Create file object from base64 data
  107. const fileBuffer = Buffer.from(fileData, 'base64');
  108. const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' });
  109. // Create attachment metadata
  110. const fileId = new ObjectID().toString();
  111. const meta = {
  112. boardId: boardId,
  113. swimlaneId: swimlaneId,
  114. listId: listId,
  115. cardId: cardId,
  116. fileId: fileId,
  117. source: 'api',
  118. storageBackend: targetStorage
  119. };
  120. // Create attachment
  121. const uploader = Attachments.insert({
  122. file: file,
  123. meta: meta,
  124. isBase64: false,
  125. transport: 'http'
  126. });
  127. if (uploader) {
  128. // Move to target storage if not filesystem
  129. if (targetStorage !== STORAGE_NAME_FILESYSTEM) {
  130. Meteor.defer(() => {
  131. try {
  132. moveToStorage(uploader, targetStorage, fileStoreStrategyFactory);
  133. } catch (error) {
  134. console.error('Error moving attachment to target storage:', error);
  135. }
  136. });
  137. }
  138. sendJsonResponse(res, 200, {
  139. success: true,
  140. attachmentId: uploader._id,
  141. fileName: fileName,
  142. fileSize: fileBuffer.length,
  143. storageBackend: targetStorage,
  144. message: 'Attachment uploaded successfully'
  145. });
  146. } else {
  147. sendErrorResponse(res, 500, 'Failed to upload attachment');
  148. }
  149. } catch (error) {
  150. console.error('API attachment upload error:', error);
  151. sendErrorResponse(res, 500, error.message);
  152. }
  153. });
  154. req.on('error', (error) => {
  155. clearTimeout(timeout);
  156. if (!res.headersSent) {
  157. console.error('Request error:', error);
  158. sendErrorResponse(res, 400, 'Request error');
  159. }
  160. });
  161. } catch (error) {
  162. clearTimeout(timeout);
  163. sendErrorResponse(res, 401, error.message);
  164. }
  165. });
  166. // Download attachment endpoint
  167. WebApp.connectHandlers.use('/api/attachment/download/([^/]+)', (req, res, next) => {
  168. if (req.method !== 'GET') {
  169. return next();
  170. }
  171. try {
  172. const userId = authenticateApiRequest(req);
  173. const attachmentId = req.params[0];
  174. // Get attachment
  175. const attachment = ReactiveCache.getAttachment(attachmentId);
  176. if (!attachment) {
  177. return sendErrorResponse(res, 404, 'Attachment not found');
  178. }
  179. // Check permissions
  180. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  181. if (!board || !board.isBoardMember(userId)) {
  182. return sendErrorResponse(res, 403, 'You do not have permission to access this attachment');
  183. }
  184. // Get file strategy
  185. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  186. const readStream = strategy.getReadStream();
  187. if (!readStream) {
  188. return sendErrorResponse(res, 404, 'File not found in storage');
  189. }
  190. // Read file data
  191. const chunks = [];
  192. readStream.on('data', (chunk) => {
  193. chunks.push(chunk);
  194. });
  195. readStream.on('end', () => {
  196. const fileBuffer = Buffer.concat(chunks);
  197. const base64Data = fileBuffer.toString('base64');
  198. sendJsonResponse(res, 200, {
  199. success: true,
  200. attachmentId: attachmentId,
  201. fileName: attachment.name,
  202. fileSize: attachment.size,
  203. fileType: attachment.type,
  204. base64Data: base64Data,
  205. storageBackend: strategy.getStorageName()
  206. });
  207. });
  208. readStream.on('error', (error) => {
  209. console.error('Download error:', error);
  210. sendErrorResponse(res, 500, error.message);
  211. });
  212. } catch (error) {
  213. sendErrorResponse(res, 401, error.message);
  214. }
  215. });
  216. // List attachments endpoint
  217. WebApp.connectHandlers.use('/api/attachment/list/([^/]+)/([^/]+)/([^/]+)/([^/]+)', (req, res, next) => {
  218. if (req.method !== 'GET') {
  219. return next();
  220. }
  221. try {
  222. const userId = authenticateApiRequest(req);
  223. const boardId = req.params[0];
  224. const swimlaneId = req.params[1];
  225. const listId = req.params[2];
  226. const cardId = req.params[3];
  227. // Check permissions
  228. const board = ReactiveCache.getBoard(boardId);
  229. if (!board || !board.isBoardMember(userId)) {
  230. return sendErrorResponse(res, 403, 'You do not have permission to access this board');
  231. }
  232. let query = { 'meta.boardId': boardId };
  233. if (swimlaneId && swimlaneId !== 'null') {
  234. query['meta.swimlaneId'] = swimlaneId;
  235. }
  236. if (listId && listId !== 'null') {
  237. query['meta.listId'] = listId;
  238. }
  239. if (cardId && cardId !== 'null') {
  240. query['meta.cardId'] = cardId;
  241. }
  242. const attachments = ReactiveCache.getAttachments(query);
  243. const attachmentList = attachments.map(attachment => {
  244. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  245. return {
  246. attachmentId: attachment._id,
  247. fileName: attachment.name,
  248. fileSize: attachment.size,
  249. fileType: attachment.type,
  250. storageBackend: strategy.getStorageName(),
  251. boardId: attachment.meta.boardId,
  252. swimlaneId: attachment.meta.swimlaneId,
  253. listId: attachment.meta.listId,
  254. cardId: attachment.meta.cardId,
  255. createdAt: attachment.uploadedAt,
  256. isImage: attachment.isImage
  257. };
  258. });
  259. sendJsonResponse(res, 200, {
  260. success: true,
  261. attachments: attachmentList,
  262. count: attachmentList.length
  263. });
  264. } catch (error) {
  265. sendErrorResponse(res, 401, error.message);
  266. }
  267. });
  268. // Copy attachment endpoint
  269. WebApp.connectHandlers.use('/api/attachment/copy', (req, res, next) => {
  270. if (req.method !== 'POST') {
  271. return next();
  272. }
  273. const timeout = setTimeout(() => {
  274. if (!res.headersSent) {
  275. sendErrorResponse(res, 408, 'Request timeout');
  276. }
  277. }, 30000);
  278. try {
  279. const userId = authenticateApiRequest(req);
  280. let body = '';
  281. let bodyComplete = false;
  282. req.on('data', chunk => {
  283. body += chunk.toString();
  284. if (body.length > 10 * 1024 * 1024) { // 10MB limit for metadata
  285. req.connection.destroy();
  286. clearTimeout(timeout);
  287. }
  288. });
  289. req.on('end', () => {
  290. if (bodyComplete) return;
  291. bodyComplete = true;
  292. clearTimeout(timeout);
  293. try {
  294. const data = JSON.parse(body);
  295. const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data;
  296. // Get source attachment
  297. const sourceAttachment = ReactiveCache.getAttachment(attachmentId);
  298. if (!sourceAttachment) {
  299. return sendErrorResponse(res, 404, 'Source attachment not found');
  300. }
  301. // Check source permissions
  302. const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId);
  303. if (!sourceBoard || !sourceBoard.isBoardMember(userId)) {
  304. return sendErrorResponse(res, 403, 'You do not have permission to access the source attachment');
  305. }
  306. // Check target permissions
  307. const targetBoard = ReactiveCache.getBoard(targetBoardId);
  308. if (!targetBoard || !targetBoard.isBoardMember(userId)) {
  309. return sendErrorResponse(res, 403, 'You do not have permission to modify the target card');
  310. }
  311. // Check if target board allows attachments
  312. if (!targetBoard.allowsAttachments) {
  313. return sendErrorResponse(res, 403, 'Attachments are not allowed on the target board');
  314. }
  315. // Get source file strategy
  316. const sourceStrategy = fileStoreStrategyFactory.getFileStrategy(sourceAttachment, 'original');
  317. const readStream = sourceStrategy.getReadStream();
  318. if (!readStream) {
  319. return sendErrorResponse(res, 404, 'Source file not found in storage');
  320. }
  321. // Read source file data
  322. const chunks = [];
  323. readStream.on('data', (chunk) => {
  324. chunks.push(chunk);
  325. });
  326. readStream.on('end', () => {
  327. try {
  328. const fileBuffer = Buffer.concat(chunks);
  329. const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type });
  330. // Create new attachment metadata
  331. const fileId = new ObjectID().toString();
  332. const meta = {
  333. boardId: targetBoardId,
  334. swimlaneId: targetSwimlaneId,
  335. listId: targetListId,
  336. cardId: targetCardId,
  337. fileId: fileId,
  338. source: 'api-copy',
  339. copyFrom: attachmentId,
  340. copyStorage: sourceStrategy.getStorageName()
  341. };
  342. // Create new attachment
  343. const uploader = Attachments.insert({
  344. file: file,
  345. meta: meta,
  346. isBase64: false,
  347. transport: 'http'
  348. });
  349. if (uploader) {
  350. sendJsonResponse(res, 200, {
  351. success: true,
  352. sourceAttachmentId: attachmentId,
  353. newAttachmentId: uploader._id,
  354. fileName: sourceAttachment.name,
  355. fileSize: sourceAttachment.size,
  356. message: 'Attachment copied successfully'
  357. });
  358. } else {
  359. sendErrorResponse(res, 500, 'Failed to copy attachment');
  360. }
  361. } catch (error) {
  362. sendErrorResponse(res, 500, error.message);
  363. }
  364. });
  365. readStream.on('error', (error) => {
  366. sendErrorResponse(res, 500, error.message);
  367. });
  368. } catch (error) {
  369. console.error('API attachment copy error:', error);
  370. sendErrorResponse(res, 500, error.message);
  371. }
  372. });
  373. req.on('error', (error) => {
  374. clearTimeout(timeout);
  375. if (!res.headersSent) {
  376. console.error('Request error:', error);
  377. sendErrorResponse(res, 400, 'Request error');
  378. }
  379. });
  380. } catch (error) {
  381. clearTimeout(timeout);
  382. sendErrorResponse(res, 401, error.message);
  383. }
  384. });
  385. // Move attachment endpoint
  386. WebApp.connectHandlers.use('/api/attachment/move', (req, res, next) => {
  387. if (req.method !== 'POST') {
  388. return next();
  389. }
  390. const timeout = setTimeout(() => {
  391. if (!res.headersSent) {
  392. sendErrorResponse(res, 408, 'Request timeout');
  393. }
  394. }, 30000);
  395. try {
  396. const userId = authenticateApiRequest(req);
  397. let body = '';
  398. let bodyComplete = false;
  399. req.on('data', chunk => {
  400. body += chunk.toString();
  401. if (body.length > 10 * 1024 * 1024) {
  402. req.connection.destroy();
  403. clearTimeout(timeout);
  404. }
  405. });
  406. req.on('end', () => {
  407. if (bodyComplete) return;
  408. bodyComplete = true;
  409. clearTimeout(timeout);
  410. try {
  411. const data = JSON.parse(body);
  412. const { attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId } = data;
  413. // Get source attachment
  414. const sourceAttachment = ReactiveCache.getAttachment(attachmentId);
  415. if (!sourceAttachment) {
  416. return sendErrorResponse(res, 404, 'Source attachment not found');
  417. }
  418. // Check source permissions
  419. const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId);
  420. if (!sourceBoard || !sourceBoard.isBoardMember(userId)) {
  421. return sendErrorResponse(res, 403, 'You do not have permission to access the source attachment');
  422. }
  423. // Check target permissions
  424. const targetBoard = ReactiveCache.getBoard(targetBoardId);
  425. if (!targetBoard || !targetBoard.isBoardMember(userId)) {
  426. return sendErrorResponse(res, 403, 'You do not have permission to modify the target card');
  427. }
  428. // Check if target board allows attachments
  429. if (!targetBoard.allowsAttachments) {
  430. return sendErrorResponse(res, 403, 'Attachments are not allowed on the target board');
  431. }
  432. // Update attachment metadata
  433. Attachments.update(attachmentId, {
  434. $set: {
  435. 'meta.boardId': targetBoardId,
  436. 'meta.swimlaneId': targetSwimlaneId,
  437. 'meta.listId': targetListId,
  438. 'meta.cardId': targetCardId,
  439. 'meta.source': 'api-move',
  440. 'meta.movedAt': new Date()
  441. }
  442. });
  443. sendJsonResponse(res, 200, {
  444. success: true,
  445. attachmentId: attachmentId,
  446. fileName: sourceAttachment.name,
  447. fileSize: sourceAttachment.size,
  448. sourceBoardId: sourceAttachment.meta.boardId,
  449. targetBoardId: targetBoardId,
  450. message: 'Attachment moved successfully'
  451. });
  452. } catch (error) {
  453. console.error('API attachment move error:', error);
  454. sendErrorResponse(res, 500, error.message);
  455. }
  456. });
  457. req.on('error', (error) => {
  458. clearTimeout(timeout);
  459. if (!res.headersSent) {
  460. console.error('Request error:', error);
  461. sendErrorResponse(res, 400, 'Request error');
  462. }
  463. });
  464. } catch (error) {
  465. clearTimeout(timeout);
  466. sendErrorResponse(res, 401, error.message);
  467. }
  468. });
  469. // Delete attachment endpoint
  470. WebApp.connectHandlers.use('/api/attachment/delete/([^/]+)', (req, res, next) => {
  471. if (req.method !== 'DELETE') {
  472. return next();
  473. }
  474. try {
  475. const userId = authenticateApiRequest(req);
  476. const attachmentId = req.params[0];
  477. // Get attachment
  478. const attachment = ReactiveCache.getAttachment(attachmentId);
  479. if (!attachment) {
  480. return sendErrorResponse(res, 404, 'Attachment not found');
  481. }
  482. // Check permissions
  483. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  484. if (!board || !board.isBoardMember(userId)) {
  485. return sendErrorResponse(res, 403, 'You do not have permission to delete this attachment');
  486. }
  487. // Delete attachment
  488. Attachments.remove(attachmentId);
  489. sendJsonResponse(res, 200, {
  490. success: true,
  491. attachmentId: attachmentId,
  492. fileName: attachment.name,
  493. message: 'Attachment deleted successfully'
  494. });
  495. } catch (error) {
  496. sendErrorResponse(res, 401, error.message);
  497. }
  498. });
  499. // Get attachment info endpoint
  500. WebApp.connectHandlers.use('/api/attachment/info/([^/]+)', (req, res, next) => {
  501. if (req.method !== 'GET') {
  502. return next();
  503. }
  504. try {
  505. const userId = authenticateApiRequest(req);
  506. const attachmentId = req.params[0];
  507. // Get attachment
  508. const attachment = ReactiveCache.getAttachment(attachmentId);
  509. if (!attachment) {
  510. return sendErrorResponse(res, 404, 'Attachment not found');
  511. }
  512. // Check permissions
  513. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  514. if (!board || !board.isBoardMember(userId)) {
  515. return sendErrorResponse(res, 403, 'You do not have permission to access this attachment');
  516. }
  517. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  518. sendJsonResponse(res, 200, {
  519. success: true,
  520. attachmentId: attachment._id,
  521. fileName: attachment.name,
  522. fileSize: attachment.size,
  523. fileType: attachment.type,
  524. storageBackend: strategy.getStorageName(),
  525. boardId: attachment.meta.boardId,
  526. swimlaneId: attachment.meta.swimlaneId,
  527. listId: attachment.meta.listId,
  528. cardId: attachment.meta.cardId,
  529. createdAt: attachment.uploadedAt,
  530. isImage: attachment.isImage,
  531. versions: Object.keys(attachment.versions).map(versionName => ({
  532. versionName: versionName,
  533. storage: attachment.versions[versionName].storage,
  534. size: attachment.versions[versionName].size,
  535. type: attachment.versions[versionName].type
  536. }))
  537. });
  538. } catch (error) {
  539. sendErrorResponse(res, 401, error.message);
  540. }
  541. });
  542. }