attachmentApi.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. import { Meteor } from 'meteor/meteor';
  2. import { ReactiveCache } from '/imports/reactiveCache';
  3. import { Attachments, fileStoreStrategyFactory } from '/models/attachments';
  4. import { moveToStorage } from '/models/lib/fileStoreStrategy';
  5. import { STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3 } from '/models/lib/fileStoreStrategy';
  6. import AttachmentStorageSettings from '/models/attachmentStorageSettings';
  7. import fs from 'fs';
  8. import path from 'path';
  9. import { ObjectID } from 'bson';
  10. // Attachment API methods
  11. if (Meteor.isServer) {
  12. Meteor.methods({
  13. // Upload attachment via API
  14. 'api.attachment.upload'(boardId, swimlaneId, listId, cardId, fileData, fileName, fileType, storageBackend) {
  15. if (!this.userId) {
  16. throw new Meteor.Error('not-authorized', 'Must be logged in');
  17. }
  18. // Validate parameters
  19. if (!boardId || !swimlaneId || !listId || !cardId || !fileData || !fileName) {
  20. throw new Meteor.Error('invalid-parameters', 'Missing required parameters');
  21. }
  22. // Check if user has permission to modify the card
  23. const card = ReactiveCache.getCard(cardId);
  24. if (!card) {
  25. throw new Meteor.Error('card-not-found', 'Card not found');
  26. }
  27. const board = ReactiveCache.getBoard(boardId);
  28. if (!board) {
  29. throw new Meteor.Error('board-not-found', 'Board not found');
  30. }
  31. // Check permissions
  32. if (!board.isBoardMember(this.userId)) {
  33. throw new Meteor.Error('not-authorized', 'You do not have permission to modify this card');
  34. }
  35. // Check if board allows attachments
  36. if (!board.allowsAttachments) {
  37. throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on this board');
  38. }
  39. // Get default storage backend if not specified
  40. let targetStorage = storageBackend;
  41. if (!targetStorage) {
  42. try {
  43. const settings = AttachmentStorageSettings.findOne({});
  44. targetStorage = settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
  45. } catch (error) {
  46. targetStorage = STORAGE_NAME_FILESYSTEM;
  47. }
  48. }
  49. // Validate storage backend
  50. if (![STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS, STORAGE_NAME_S3].includes(targetStorage)) {
  51. throw new Meteor.Error('invalid-storage', 'Invalid storage backend');
  52. }
  53. try {
  54. // Create file object from base64 data
  55. const fileBuffer = Buffer.from(fileData, 'base64');
  56. const file = new File([fileBuffer], fileName, { type: fileType || 'application/octet-stream' });
  57. // Create attachment metadata
  58. const fileId = new ObjectID().toString();
  59. const meta = {
  60. boardId: boardId,
  61. swimlaneId: swimlaneId,
  62. listId: listId,
  63. cardId: cardId,
  64. fileId: fileId,
  65. source: 'api',
  66. storageBackend: targetStorage
  67. };
  68. // Create attachment
  69. const uploader = Attachments.insert({
  70. file: file,
  71. meta: meta,
  72. isBase64: false,
  73. transport: 'http'
  74. });
  75. if (uploader) {
  76. // Move to target storage if not filesystem
  77. if (targetStorage !== STORAGE_NAME_FILESYSTEM) {
  78. Meteor.defer(() => {
  79. try {
  80. moveToStorage(uploader, targetStorage, fileStoreStrategyFactory);
  81. } catch (error) {
  82. console.error('Error moving attachment to target storage:', error);
  83. }
  84. });
  85. }
  86. return {
  87. success: true,
  88. attachmentId: uploader._id,
  89. fileName: fileName,
  90. fileSize: fileBuffer.length,
  91. storageBackend: targetStorage,
  92. message: 'Attachment uploaded successfully'
  93. };
  94. } else {
  95. throw new Meteor.Error('upload-failed', 'Failed to upload attachment');
  96. }
  97. } catch (error) {
  98. console.error('API attachment upload error:', error);
  99. throw new Meteor.Error('upload-error', error.message);
  100. }
  101. },
  102. // Download attachment via API
  103. 'api.attachment.download'(attachmentId) {
  104. if (!this.userId) {
  105. throw new Meteor.Error('not-authorized', 'Must be logged in');
  106. }
  107. // Get attachment
  108. const attachment = ReactiveCache.getAttachment(attachmentId);
  109. if (!attachment) {
  110. throw new Meteor.Error('attachment-not-found', 'Attachment not found');
  111. }
  112. // Check permissions
  113. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  114. if (!board || !board.isBoardMember(this.userId)) {
  115. throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment');
  116. }
  117. try {
  118. // Get file strategy
  119. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  120. const readStream = strategy.getReadStream();
  121. if (!readStream) {
  122. throw new Meteor.Error('file-not-found', 'File not found in storage');
  123. }
  124. // Read file data
  125. const chunks = [];
  126. return new Promise((resolve, reject) => {
  127. readStream.on('data', (chunk) => {
  128. chunks.push(chunk);
  129. });
  130. readStream.on('end', () => {
  131. const fileBuffer = Buffer.concat(chunks);
  132. const base64Data = fileBuffer.toString('base64');
  133. resolve({
  134. success: true,
  135. attachmentId: attachmentId,
  136. fileName: attachment.name,
  137. fileSize: attachment.size,
  138. fileType: attachment.type,
  139. base64Data: base64Data,
  140. storageBackend: strategy.getStorageName()
  141. });
  142. });
  143. readStream.on('error', (error) => {
  144. reject(new Meteor.Error('download-error', error.message));
  145. });
  146. });
  147. } catch (error) {
  148. console.error('API attachment download error:', error);
  149. throw new Meteor.Error('download-error', error.message);
  150. }
  151. },
  152. // List attachments for board, swimlane, list, or card
  153. 'api.attachment.list'(boardId, swimlaneId, listId, cardId) {
  154. if (!this.userId) {
  155. throw new Meteor.Error('not-authorized', 'Must be logged in');
  156. }
  157. // Check permissions
  158. const board = ReactiveCache.getBoard(boardId);
  159. if (!board || !board.isBoardMember(this.userId)) {
  160. throw new Meteor.Error('not-authorized', 'You do not have permission to access this board');
  161. }
  162. try {
  163. let query = { 'meta.boardId': boardId };
  164. if (swimlaneId) {
  165. query['meta.swimlaneId'] = swimlaneId;
  166. }
  167. if (listId) {
  168. query['meta.listId'] = listId;
  169. }
  170. if (cardId) {
  171. query['meta.cardId'] = cardId;
  172. }
  173. const attachments = ReactiveCache.getAttachments(query);
  174. const attachmentList = attachments.map(attachment => {
  175. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  176. return {
  177. attachmentId: attachment._id,
  178. fileName: attachment.name,
  179. fileSize: attachment.size,
  180. fileType: attachment.type,
  181. storageBackend: strategy.getStorageName(),
  182. boardId: attachment.meta.boardId,
  183. swimlaneId: attachment.meta.swimlaneId,
  184. listId: attachment.meta.listId,
  185. cardId: attachment.meta.cardId,
  186. createdAt: attachment.uploadedAt,
  187. isImage: attachment.isImage
  188. };
  189. });
  190. return {
  191. success: true,
  192. attachments: attachmentList,
  193. count: attachmentList.length
  194. };
  195. } catch (error) {
  196. console.error('API attachment list error:', error);
  197. throw new Meteor.Error('list-error', error.message);
  198. }
  199. },
  200. // Copy attachment to another card
  201. 'api.attachment.copy'(attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId) {
  202. if (!this.userId) {
  203. throw new Meteor.Error('not-authorized', 'Must be logged in');
  204. }
  205. // Get source attachment
  206. const sourceAttachment = ReactiveCache.getAttachment(attachmentId);
  207. if (!sourceAttachment) {
  208. throw new Meteor.Error('attachment-not-found', 'Source attachment not found');
  209. }
  210. // Check source permissions
  211. const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId);
  212. if (!sourceBoard || !sourceBoard.isBoardMember(this.userId)) {
  213. throw new Meteor.Error('not-authorized', 'You do not have permission to access the source attachment');
  214. }
  215. // Check target permissions
  216. const targetBoard = ReactiveCache.getBoard(targetBoardId);
  217. if (!targetBoard || !targetBoard.isBoardMember(this.userId)) {
  218. throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card');
  219. }
  220. // Check if target board allows attachments
  221. if (!targetBoard.allowsAttachments) {
  222. throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on the target board');
  223. }
  224. try {
  225. // Get source file strategy
  226. const sourceStrategy = fileStoreStrategyFactory.getFileStrategy(sourceAttachment, 'original');
  227. const readStream = sourceStrategy.getReadStream();
  228. if (!readStream) {
  229. throw new Meteor.Error('file-not-found', 'Source file not found in storage');
  230. }
  231. // Read source file data
  232. const chunks = [];
  233. return new Promise((resolve, reject) => {
  234. readStream.on('data', (chunk) => {
  235. chunks.push(chunk);
  236. });
  237. readStream.on('end', () => {
  238. try {
  239. const fileBuffer = Buffer.concat(chunks);
  240. const file = new File([fileBuffer], sourceAttachment.name, { type: sourceAttachment.type });
  241. // Create new attachment metadata
  242. const fileId = new ObjectID().toString();
  243. const meta = {
  244. boardId: targetBoardId,
  245. swimlaneId: targetSwimlaneId,
  246. listId: targetListId,
  247. cardId: targetCardId,
  248. fileId: fileId,
  249. source: 'api-copy',
  250. copyFrom: attachmentId,
  251. copyStorage: sourceStrategy.getStorageName()
  252. };
  253. // Create new attachment
  254. const uploader = Attachments.insert({
  255. file: file,
  256. meta: meta,
  257. isBase64: false,
  258. transport: 'http'
  259. });
  260. if (uploader) {
  261. resolve({
  262. success: true,
  263. sourceAttachmentId: attachmentId,
  264. newAttachmentId: uploader._id,
  265. fileName: sourceAttachment.name,
  266. fileSize: sourceAttachment.size,
  267. message: 'Attachment copied successfully'
  268. });
  269. } else {
  270. reject(new Meteor.Error('copy-failed', 'Failed to copy attachment'));
  271. }
  272. } catch (error) {
  273. reject(new Meteor.Error('copy-error', error.message));
  274. }
  275. });
  276. readStream.on('error', (error) => {
  277. reject(new Meteor.Error('copy-error', error.message));
  278. });
  279. });
  280. } catch (error) {
  281. console.error('API attachment copy error:', error);
  282. throw new Meteor.Error('copy-error', error.message);
  283. }
  284. },
  285. // Move attachment to another card
  286. 'api.attachment.move'(attachmentId, targetBoardId, targetSwimlaneId, targetListId, targetCardId) {
  287. if (!this.userId) {
  288. throw new Meteor.Error('not-authorized', 'Must be logged in');
  289. }
  290. // Get source attachment
  291. const sourceAttachment = ReactiveCache.getAttachment(attachmentId);
  292. if (!sourceAttachment) {
  293. throw new Meteor.Error('attachment-not-found', 'Source attachment not found');
  294. }
  295. // Check source permissions
  296. const sourceBoard = ReactiveCache.getBoard(sourceAttachment.meta.boardId);
  297. if (!sourceBoard || !sourceBoard.isBoardMember(this.userId)) {
  298. throw new Meteor.Error('not-authorized', 'You do not have permission to access the source attachment');
  299. }
  300. // Check target permissions
  301. const targetBoard = ReactiveCache.getBoard(targetBoardId);
  302. if (!targetBoard || !targetBoard.isBoardMember(this.userId)) {
  303. throw new Meteor.Error('not-authorized', 'You do not have permission to modify the target card');
  304. }
  305. // Check if target board allows attachments
  306. if (!targetBoard.allowsAttachments) {
  307. throw new Meteor.Error('attachments-not-allowed', 'Attachments are not allowed on the target board');
  308. }
  309. try {
  310. // Update attachment metadata
  311. Attachments.update(attachmentId, {
  312. $set: {
  313. 'meta.boardId': targetBoardId,
  314. 'meta.swimlaneId': targetSwimlaneId,
  315. 'meta.listId': targetListId,
  316. 'meta.cardId': targetCardId,
  317. 'meta.source': 'api-move',
  318. 'meta.movedAt': new Date()
  319. }
  320. });
  321. return {
  322. success: true,
  323. attachmentId: attachmentId,
  324. fileName: sourceAttachment.name,
  325. fileSize: sourceAttachment.size,
  326. sourceBoardId: sourceAttachment.meta.boardId,
  327. targetBoardId: targetBoardId,
  328. message: 'Attachment moved successfully'
  329. };
  330. } catch (error) {
  331. console.error('API attachment move error:', error);
  332. throw new Meteor.Error('move-error', error.message);
  333. }
  334. },
  335. // Delete attachment via API
  336. 'api.attachment.delete'(attachmentId) {
  337. if (!this.userId) {
  338. throw new Meteor.Error('not-authorized', 'Must be logged in');
  339. }
  340. // Get attachment
  341. const attachment = ReactiveCache.getAttachment(attachmentId);
  342. if (!attachment) {
  343. throw new Meteor.Error('attachment-not-found', 'Attachment not found');
  344. }
  345. // Check permissions
  346. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  347. if (!board || !board.isBoardMember(this.userId)) {
  348. throw new Meteor.Error('not-authorized', 'You do not have permission to delete this attachment');
  349. }
  350. try {
  351. // Delete attachment
  352. Attachments.remove(attachmentId);
  353. return {
  354. success: true,
  355. attachmentId: attachmentId,
  356. fileName: attachment.name,
  357. message: 'Attachment deleted successfully'
  358. };
  359. } catch (error) {
  360. console.error('API attachment delete error:', error);
  361. throw new Meteor.Error('delete-error', error.message);
  362. }
  363. },
  364. // Get attachment info via API
  365. 'api.attachment.info'(attachmentId) {
  366. if (!this.userId) {
  367. throw new Meteor.Error('not-authorized', 'Must be logged in');
  368. }
  369. // Get attachment
  370. const attachment = ReactiveCache.getAttachment(attachmentId);
  371. if (!attachment) {
  372. throw new Meteor.Error('attachment-not-found', 'Attachment not found');
  373. }
  374. // Check permissions
  375. const board = ReactiveCache.getBoard(attachment.meta.boardId);
  376. if (!board || !board.isBoardMember(this.userId)) {
  377. throw new Meteor.Error('not-authorized', 'You do not have permission to access this attachment');
  378. }
  379. try {
  380. const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
  381. return {
  382. success: true,
  383. attachmentId: attachment._id,
  384. fileName: attachment.name,
  385. fileSize: attachment.size,
  386. fileType: attachment.type,
  387. storageBackend: strategy.getStorageName(),
  388. boardId: attachment.meta.boardId,
  389. swimlaneId: attachment.meta.swimlaneId,
  390. listId: attachment.meta.listId,
  391. cardId: attachment.meta.cardId,
  392. createdAt: attachment.uploadedAt,
  393. isImage: attachment.isImage,
  394. versions: Object.keys(attachment.versions).map(versionName => ({
  395. versionName: versionName,
  396. storage: attachment.versions[versionName].storage,
  397. size: attachment.versions[versionName].size,
  398. type: attachment.versions[versionName].type
  399. }))
  400. };
  401. } catch (error) {
  402. console.error('API attachment info error:', error);
  403. throw new Meteor.Error('info-error', error.message);
  404. }
  405. }
  406. });
  407. }