attachments.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { ObjectID } from 'bson';
  3. import DOMPurify from 'dompurify';
  4. import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
  5. import uploadProgressManager from '../../lib/uploadProgressManager';
  6. import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
  7. const filesize = require('filesize');
  8. const prettyMilliseconds = require('pretty-ms');
  9. // We store current card ID and the ID of currently opened attachment in a
  10. // global var. This is used so that we know what's the next attachment to open
  11. // when the user clicks on the prev/next button in the attachment viewer.
  12. let cardId = null;
  13. let openAttachmentId = null;
  14. // Used to store the start and end coordinates of a touch event for attachment swiping
  15. let touchStartCoords = null;
  16. let touchEndCoords = null;
  17. // Stores link to the attachment for which attachment actions popup was opened
  18. attachmentActionsLink = null;
  19. Template.attachmentGallery.events({
  20. 'click .open-preview'(event) {
  21. openAttachmentId = $(event.currentTarget).attr("data-attachment-id");
  22. cardId = $(event.currentTarget).attr("data-card-id");
  23. openAttachmentViewer(openAttachmentId);
  24. },
  25. 'click .js-add-attachment': Popup.open('cardAttachments'),
  26. // If we let this event bubble, FlowRouter will handle it and empty the page
  27. // content, see #101.
  28. 'click .js-download'(event) {
  29. event.stopPropagation();
  30. },
  31. 'click .js-open-attachment-menu': Popup.open('attachmentActions'),
  32. 'mouseover .js-open-attachment-menu'(event) { // For some reason I cannot combine handlers for "click .js-open-attachment-menu" and "mouseover .js-open-attachment-menu" events so this is a quick workaround.
  33. attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");
  34. },
  35. 'click .js-rename': Popup.open('attachmentRename'),
  36. 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {
  37. Attachments.remove(this._id);
  38. Popup.back();
  39. }),
  40. });
  41. function getNextAttachmentId(currentAttachmentId, offset = 0) {
  42. const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
  43. let i = 0;
  44. for (; i < attachments.length; i++) {
  45. if (attachments[i]._id === currentAttachmentId) {
  46. break;
  47. }
  48. }
  49. return attachments[(i + offset + 1 + attachments.length) % attachments.length]._id;
  50. }
  51. function getPrevAttachmentId(currentAttachmentId, offset = 0) {
  52. const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
  53. let i = 0;
  54. for (; i < attachments.length; i++) {
  55. if (attachments[i]._id === currentAttachmentId) {
  56. break;
  57. }
  58. }
  59. return attachments[(i + offset - 1 + attachments.length) % attachments.length]._id;
  60. }
  61. function attachmentCanBeOpened(attachment) {
  62. return (
  63. attachment.isImage ||
  64. attachment.isPDF ||
  65. attachment.isText ||
  66. attachment.isJSON ||
  67. attachment.isVideo ||
  68. attachment.isAudio
  69. );
  70. }
  71. function openAttachmentViewer(attachmentId) {
  72. const attachment = ReactiveCache.getAttachment(attachmentId);
  73. // Check if we can open the attachment (if we have a viewer for it) and exit if not
  74. if (!attachmentCanBeOpened(attachment)) {
  75. return;
  76. }
  77. /*
  78. Instructions for adding a new viewer:
  79. - add a new case to the switch statement below
  80. - implement cleanup in the closeAttachmentViewer() function, if necessary
  81. - mark attachment type as openable by adding a new condition to the attachmentCanBeOpened function
  82. */
  83. switch(true){
  84. case (attachment.isImage):
  85. $("#image-viewer").attr("src", attachment.link());
  86. $("#image-viewer").removeClass("hidden");
  87. break;
  88. case (attachment.isPDF):
  89. $("#pdf-viewer").attr("data", attachment.link());
  90. $("#pdf-viewer").removeClass("hidden");
  91. break;
  92. case (attachment.isVideo):
  93. // We have to create a new <source> DOM element and append it to the video
  94. // element, otherwise the video won't load
  95. let videoSource = document.createElement('source');
  96. videoSource.setAttribute('src', attachment.link());
  97. $("#video-viewer").append(videoSource);
  98. $("#video-viewer").removeClass("hidden");
  99. break;
  100. case (attachment.isAudio):
  101. // We have to create a new <source> DOM element and append it to the audio
  102. // element, otherwise the audio won't load
  103. let audioSource = document.createElement('source');
  104. audioSource.setAttribute('src', attachment.link());
  105. $("#audio-viewer").append(audioSource);
  106. $("#audio-viewer").removeClass("hidden");
  107. break;
  108. case (attachment.isText):
  109. case (attachment.isJSON):
  110. $("#txt-viewer").attr("data", attachment.link());
  111. $("#txt-viewer").removeClass("hidden");
  112. break;
  113. }
  114. $('#attachment-name').text(attachment.name);
  115. $('#viewer-overlay').removeClass('hidden');
  116. }
  117. function closeAttachmentViewer() {
  118. $("#viewer-overlay").addClass("hidden");
  119. // We need to reset the viewers to avoid showing previous attachments
  120. $("#image-viewer").attr("src", "");
  121. $("#image-viewer").addClass("hidden");
  122. $("#pdf-viewer").attr("data", "");
  123. $("#pdf-viewer").addClass("hidden");
  124. $("#txt-viewer").attr("data", "");
  125. $("#txt-viewer").addClass("hidden");
  126. $("#video-viewer").get(0).pause(); // Stop playback
  127. $("#video-viewer").get(0).currentTime = 0;
  128. $("#video-viewer").empty();
  129. $("#video-viewer").addClass("hidden");
  130. $("#audio-viewer").get(0).pause(); // Stop playback
  131. $("#audio-viewer").get(0).currentTime = 0;
  132. $("#audio-viewer").empty();
  133. $("#audio-viewer").addClass("hidden");
  134. }
  135. function openNextAttachment() {
  136. closeAttachmentViewer();
  137. let i = 0;
  138. // Find an attachment that can be opened
  139. while (true) {
  140. const id = getNextAttachmentId(openAttachmentId, i);
  141. const attachment = ReactiveCache.getAttachment(id);
  142. if (attachmentCanBeOpened(attachment)) {
  143. openAttachmentId = id;
  144. openAttachmentViewer(id);
  145. break;
  146. }
  147. i++;
  148. }
  149. }
  150. function openPrevAttachment() {
  151. closeAttachmentViewer();
  152. let i = 0;
  153. // Find an attachment that can be opened
  154. while (true) {
  155. const id = getPrevAttachmentId(openAttachmentId, i);
  156. const attachment = ReactiveCache.getAttachment(id);
  157. if (attachmentCanBeOpened(attachment)) {
  158. openAttachmentId = id;
  159. openAttachmentViewer(id);
  160. break;
  161. }
  162. i--;
  163. }
  164. }
  165. function processTouch(){
  166. xDist = touchEndCoords.x - touchStartCoords.x;
  167. yDist = touchEndCoords.y - touchStartCoords.y;
  168. console.log("xDist: " + xDist);
  169. // Left swipe
  170. if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
  171. openNextAttachment();
  172. }
  173. // Right swipe
  174. if (Math.abs(xDist) > Math.abs(yDist) && xDist > 0) {
  175. openPrevAttachment();
  176. }
  177. // Up swipe
  178. if (Math.abs(yDist) > Math.abs(xDist) && yDist < 0) {
  179. closeAttachmentViewer();
  180. }
  181. }
  182. Template.attachmentViewer.events({
  183. 'touchstart #viewer-container'(event) {
  184. console.log("touchstart")
  185. touchStartCoords = {
  186. x: event.changedTouches[0].screenX,
  187. y: event.changedTouches[0].screenY
  188. }
  189. },
  190. 'touchend #viewer-container'(event) {
  191. console.log("touchend")
  192. touchEndCoords = {
  193. x: event.changedTouches[0].screenX,
  194. y: event.changedTouches[0].screenY
  195. }
  196. processTouch();
  197. },
  198. 'click #viewer-container'(event) {
  199. // Make sure the click was on #viewer-container and not on any of its children
  200. if(event.target !== event.currentTarget) {
  201. event.stopPropagation();
  202. return;
  203. }
  204. closeAttachmentViewer();
  205. },
  206. 'click #viewer-content'(event) {
  207. // Make sure the click was on #viewer-content and not on any of its children
  208. if(event.target !== event.currentTarget) {
  209. event.stopPropagation();
  210. return;
  211. }
  212. closeAttachmentViewer();
  213. },
  214. 'click #viewer-close'() {
  215. closeAttachmentViewer();
  216. },
  217. 'click #next-attachment'() {
  218. openNextAttachment();
  219. },
  220. 'click #prev-attachment'() {
  221. openPrevAttachment();
  222. },
  223. });
  224. Template.attachmentGallery.helpers({
  225. isBoardAdmin() {
  226. return ReactiveCache.getCurrentUser().isBoardAdmin();
  227. },
  228. fileSize(size) {
  229. const ret = filesize(size);
  230. return ret;
  231. },
  232. sanitize(value) {
  233. return sanitizeHTML(value);
  234. },
  235. });
  236. Template.cardAttachmentsPopup.onCreated(function() {
  237. this.uploads = new ReactiveVar([]);
  238. });
  239. Template.cardAttachmentsPopup.helpers({
  240. getEstimateTime(upload) {
  241. const ret = prettyMilliseconds(upload.estimateTime.get());
  242. return ret;
  243. },
  244. getEstimateSpeed(upload) {
  245. const ret = filesize(upload.estimateSpeed.get(), {round: 0}) + "/s";
  246. return ret;
  247. },
  248. uploads() {
  249. return Template.instance().uploads.get();
  250. }
  251. });
  252. Template.cardAttachmentsPopup.events({
  253. 'change .js-attach-file'(event, templateInstance) {
  254. const card = this;
  255. const files = event.currentTarget.files;
  256. if (files) {
  257. let uploads = [];
  258. const uploaders = handleFileUpload(card, files);
  259. uploaders.forEach(uploader => {
  260. uploader.on('start', function() {
  261. uploads.push(this);
  262. templateInstance.uploads.set(uploads);
  263. });
  264. uploader.on('end', (error, fileRef) => {
  265. uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);
  266. templateInstance.uploads.set(uploads);
  267. if (uploads.length == 0 ) {
  268. Popup.back();
  269. }
  270. });
  271. });
  272. }
  273. },
  274. 'click .js-computer-upload'(event, templateInstance) {
  275. templateInstance.find('.js-attach-file').click();
  276. event.preventDefault();
  277. },
  278. 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
  279. });
  280. const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
  281. const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
  282. let pastedResults = null;
  283. // Shared upload logic for drag-and-drop functionality
  284. export function handleFileUpload(card, files) {
  285. if (!files || files.length === 0) {
  286. return [];
  287. }
  288. // Check if board allows attachments
  289. const board = card.board();
  290. if (!board || !board.allowsAttachments) {
  291. if (process.env.DEBUG === 'true') {
  292. console.warn('Attachments not allowed on this board');
  293. }
  294. return [];
  295. }
  296. // Check if user can modify the card
  297. if (!card.canModifyCard()) {
  298. if (process.env.DEBUG === 'true') {
  299. console.warn('User does not have permission to modify this card');
  300. }
  301. return [];
  302. }
  303. const uploads = [];
  304. for (const file of files) {
  305. // Basic file validation
  306. if (!file || !file.name) {
  307. if (process.env.DEBUG === 'true') {
  308. console.warn('Invalid file object');
  309. }
  310. continue;
  311. }
  312. const fileId = new ObjectID().toString();
  313. let fileName = sanitizeText(file.name);
  314. // If sanitized filename is not same as original filename,
  315. // it could be XSS that is already fixed with sanitize,
  316. // or just normal mistake, so it is not a problem.
  317. // That is why here is no warning.
  318. if (fileName !== file.name) {
  319. // If filename is empty, only in that case add some filename
  320. if (fileName.length === 0) {
  321. fileName = 'Empty-filename-after-sanitize.txt';
  322. }
  323. }
  324. const config = {
  325. file: file,
  326. fileId: fileId,
  327. fileName: fileName,
  328. meta: Utils.getCommonAttachmentMetaFrom(card),
  329. chunkSize: 'dynamic',
  330. };
  331. config.meta.fileId = fileId;
  332. try {
  333. const uploader = Attachments.insert(
  334. config,
  335. false,
  336. );
  337. // Add to progress manager for tracking
  338. const uploadId = uploadProgressManager.addUpload(card._id, uploader, file);
  339. uploader.on('uploaded', (error, fileRef) => {
  340. if (!error) {
  341. if (fileRef.isImage) {
  342. card.setCover(fileRef._id);
  343. if (process.env.DEBUG === 'true') {
  344. console.log(`Set cover image for card ${card._id}: ${fileRef.name}`);
  345. }
  346. }
  347. } else {
  348. if (process.env.DEBUG === 'true') {
  349. console.error('Upload error:', error);
  350. }
  351. }
  352. });
  353. uploader.on('error', (error) => {
  354. if (process.env.DEBUG === 'true') {
  355. console.error('Upload error:', error);
  356. }
  357. });
  358. uploads.push(uploader);
  359. uploader.start();
  360. } catch (error) {
  361. if (process.env.DEBUG === 'true') {
  362. console.error('Failed to create uploader:', error);
  363. }
  364. }
  365. }
  366. return uploads;
  367. }
  368. Template.previewClipboardImagePopup.onRendered(() => {
  369. // we can paste image from clipboard
  370. const handle = results => {
  371. if (results.dataURL.startsWith('data:image/')) {
  372. const direct = results => {
  373. $('img.preview-clipboard-image').attr('src', results.dataURL);
  374. pastedResults = results;
  375. };
  376. if (MAX_IMAGE_PIXEL) {
  377. // if has size limitation on image we shrink it before uploading
  378. Utils.shrinkImage({
  379. dataurl: results.dataURL,
  380. maxSize: MAX_IMAGE_PIXEL,
  381. ratio: COMPRESS_RATIO,
  382. callback(changed) {
  383. if (changed !== false && !!changed) {
  384. results.dataURL = changed;
  385. }
  386. direct(results);
  387. },
  388. });
  389. } else {
  390. direct(results);
  391. }
  392. }
  393. };
  394. $(document.body).pasteImageReader(handle);
  395. // we can also drag & drop image file to it
  396. $(document.body).dropImageReader(handle);
  397. });
  398. Template.previewClipboardImagePopup.events({
  399. 'click .js-upload-pasted-image'() {
  400. const card = this;
  401. if (pastedResults && pastedResults.file) {
  402. const file = pastedResults.file;
  403. window.oPasted = pastedResults;
  404. const fileId = new ObjectID().toString();
  405. const config = {
  406. file,
  407. fileId: fileId,
  408. meta: Utils.getCommonAttachmentMetaFrom(card),
  409. fileName: file.name || file.type.replace('image/', 'clipboard.'),
  410. chunkSize: 'dynamic',
  411. };
  412. config.meta.fileId = fileId;
  413. const uploader = Attachments.insert(
  414. config,
  415. false,
  416. );
  417. uploader.on('uploaded', (error, fileRef) => {
  418. if (!error) {
  419. if (fileRef.isImage) {
  420. card.setCover(fileRef._id);
  421. }
  422. }
  423. });
  424. uploader.on('end', (error, fileRef) => {
  425. pastedResults = null;
  426. $(document.body).pasteImageReader(() => {});
  427. Popup.back();
  428. });
  429. uploader.start();
  430. }
  431. },
  432. });
  433. BlazeComponent.extendComponent({
  434. isCover() {
  435. const ret = ReactiveCache.getCard(this.data().meta.cardId).coverId == this.data()._id;
  436. return ret;
  437. },
  438. isBackgroundImage() {
  439. //const currentBoard = Utils.getCurrentBoard();
  440. //return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");
  441. return false;
  442. },
  443. events() {
  444. return [
  445. {
  446. 'click .js-add-cover'() {
  447. ReactiveCache.getCard(this.data().meta.cardId).setCover(this.data()._id);
  448. Popup.back();
  449. },
  450. 'click .js-remove-cover'() {
  451. ReactiveCache.getCard(this.data().meta.cardId).unsetCover();
  452. Popup.back();
  453. },
  454. 'click .js-add-background-image'() {
  455. const currentBoard = Utils.getCurrentBoard();
  456. currentBoard.setBackgroundImageURL(attachmentActionsLink);
  457. Utils.setBackgroundImage(attachmentActionsLink);
  458. Popup.back();
  459. event.preventDefault();
  460. },
  461. 'click .js-remove-background-image'() {
  462. const currentBoard = Utils.getCurrentBoard();
  463. currentBoard.setBackgroundImageURL("");
  464. Utils.setBackgroundImage("");
  465. Popup.back();
  466. Utils.reload();
  467. event.preventDefault();
  468. },
  469. 'click .js-move-storage-fs'() {
  470. Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");
  471. Popup.back();
  472. },
  473. 'click .js-move-storage-gridfs'() {
  474. Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");
  475. Popup.back();
  476. },
  477. 'click .js-move-storage-s3'() {
  478. Meteor.call('moveAttachmentToStorage', this.data()._id, "s3");
  479. Popup.back();
  480. },
  481. }
  482. ]
  483. }
  484. }).register('attachmentActionsPopup');
  485. BlazeComponent.extendComponent({
  486. getNameWithoutExtension() {
  487. const ret = this.data().name.replace(new RegExp("\." + this.data().extension + "$"), "");
  488. return ret;
  489. },
  490. events() {
  491. return [
  492. {
  493. 'keydown input.js-edit-attachment-name'(evt) {
  494. // enter = save
  495. if (evt.keyCode === 13) {
  496. this.find('button[type=submit]').click();
  497. }
  498. },
  499. 'click button.js-submit-edit-attachment-name'(event) {
  500. // save button pressed
  501. event.preventDefault();
  502. const name = this.$('.js-edit-attachment-name')[0]
  503. .value
  504. .trim() + this.data().extensionWithDot;
  505. if (name === sanitizeText(name)) {
  506. Meteor.call('renameAttachment', this.data()._id, name);
  507. }
  508. Popup.back();
  509. },
  510. }
  511. ]
  512. }
  513. }).register('attachmentRenamePopup');
  514. // Template helpers for attachment migration status
  515. Template.registerHelper('attachmentMigrationStatus', function(attachmentId) {
  516. return attachmentMigrationManager.getAttachmentMigrationStatus(attachmentId);
  517. });
  518. Template.registerHelper('isAttachmentMigrating', function(attachmentId) {
  519. return attachmentMigrationManager.isAttachmentBeingMigrated(attachmentId);
  520. });
  521. Template.registerHelper('attachmentMigrationProgress', function() {
  522. return attachmentMigrationManager.attachmentMigrationProgress.get();
  523. });
  524. Template.registerHelper('attachmentMigrationStatusText', function() {
  525. return attachmentMigrationManager.attachmentMigrationStatus.get();
  526. });