attachments.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. import { ObjectID } from 'bson';
  3. import DOMPurify from 'dompurify';
  4. const filesize = require('filesize');
  5. const prettyMilliseconds = require('pretty-ms');
  6. // We store current card ID and the ID of currently opened attachment in a
  7. // global var. This is used so that we know what's the next attachment to open
  8. // when the user clicks on the prev/next button in the attachment viewer.
  9. let cardId = null;
  10. let openAttachmentId = null;
  11. // Stores link to the attachment for which attachment actions popup was opened
  12. attachmentActionsLink = null;
  13. Template.attachmentGallery.events({
  14. 'click .open-preview'(event) {
  15. openAttachmentId = $(event.currentTarget).attr("data-attachment-id");
  16. cardId = $(event.currentTarget).attr("data-card-id");
  17. openAttachmentViewer(openAttachmentId);
  18. },
  19. 'click .js-add-attachment': Popup.open('cardAttachments'),
  20. // If we let this event bubble, FlowRouter will handle it and empty the page
  21. // content, see #101.
  22. 'click .js-download'(event) {
  23. event.stopPropagation();
  24. },
  25. 'click .js-open-attachment-menu': Popup.open('attachmentActions'),
  26. '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.
  27. attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");
  28. },
  29. 'click .js-rename': Popup.open('attachmentRename'),
  30. 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {
  31. Attachments.remove(this._id);
  32. Popup.back(2);
  33. }),
  34. });
  35. function getNextAttachmentId(currentAttachmentId, offset = 0) {
  36. const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
  37. let i = 0;
  38. for (; i < attachments.length; i++) {
  39. if (attachments[i]._id === currentAttachmentId) {
  40. break;
  41. }
  42. }
  43. return attachments[(i + offset + 1 + attachments.length) % attachments.length]._id;
  44. }
  45. function getPrevAttachmentId(currentAttachmentId, offset = 0) {
  46. const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
  47. let i = 0;
  48. for (; i < attachments.length; i++) {
  49. if (attachments[i]._id === currentAttachmentId) {
  50. break;
  51. }
  52. }
  53. return attachments[(i + offset - 1 + attachments.length) % attachments.length]._id;
  54. }
  55. function attachmentCanBeOpened(attachment) {
  56. return (
  57. attachment.isImage ||
  58. attachment.isPDF ||
  59. attachment.isText ||
  60. attachment.isJSON ||
  61. attachment.isVideo ||
  62. attachment.isAudio
  63. );
  64. }
  65. function openAttachmentViewer(attachmentId) {
  66. const attachment = ReactiveCache.getAttachment(attachmentId);
  67. // Check if we can open the attachment (if we have a viewer for it) and exit if not
  68. if (!attachmentCanBeOpened(attachment)) {
  69. return;
  70. }
  71. /*
  72. Instructions for adding a new viewer:
  73. - add a new case to the switch statement below
  74. - implement cleanup in the closeAttachmentViewer() function, if necessary
  75. - mark attachment type as openable by adding a new condition to the attachmentCanBeOpened function
  76. */
  77. switch(true){
  78. case (attachment.isImage):
  79. $("#image-viewer").attr("src", attachment.link());
  80. $("#image-viewer").removeClass("hidden");
  81. break;
  82. case (attachment.isPDF):
  83. $("#pdf-viewer").attr("data", attachment.link());
  84. $("#pdf-viewer").removeClass("hidden");
  85. break;
  86. case (attachment.isVideo):
  87. // We have to create a new <source> DOM element and append it to the video
  88. // element, otherwise the video won't load
  89. let videoSource = document.createElement('source');
  90. videoSource.setAttribute('src', attachment.link());
  91. $("#video-viewer").append(videoSource);
  92. $("#video-viewer").removeClass("hidden");
  93. break;
  94. case (attachment.isAudio):
  95. // We have to create a new <source> DOM element and append it to the audio
  96. // element, otherwise the audio won't load
  97. let audioSource = document.createElement('source');
  98. audioSource.setAttribute('src', attachment.link());
  99. $("#audio-viewer").append(audioSource);
  100. $("#audio-viewer").removeClass("hidden");
  101. break;
  102. case (attachment.isText):
  103. case (attachment.isJSON):
  104. $("#txt-viewer").attr("data", attachment.link());
  105. $("#txt-viewer").removeClass("hidden");
  106. break;
  107. }
  108. $('#attachment-name').text(attachment.name);
  109. $('#viewer-overlay').removeClass('hidden');
  110. }
  111. function closeAttachmentViewer() {
  112. $("#viewer-overlay").addClass("hidden");
  113. // We need to reset the viewers to avoid showing previous attachments
  114. $("#image-viewer").attr("src", "");
  115. $("#image-viewer").addClass("hidden");
  116. $("#pdf-viewer").attr("data", "");
  117. $("#pdf-viewer").addClass("hidden");
  118. $("#txt-viewer").attr("data", "");
  119. $("#txt-viewer").addClass("hidden");
  120. $("#video-viewer").get(0).pause(); // Stop playback
  121. $("#video-viewer").get(0).currentTime = 0;
  122. $("#video-viewer").empty();
  123. $("#video-viewer").addClass("hidden");
  124. $("#audio-viewer").get(0).pause(); // Stop playback
  125. $("#audio-viewer").get(0).currentTime = 0;
  126. $("#audio-viewer").empty();
  127. $("#audio-viewer").addClass("hidden");
  128. }
  129. Template.attachmentViewer.events({
  130. 'click #viewer-container'(event) {
  131. // Make sure the click was on #viewer-container and not on any of its children
  132. if(event.target !== event.currentTarget) return;
  133. closeAttachmentViewer();
  134. },
  135. 'click #viewer-content'(event) {
  136. // Make sure the click was on #viewer-content and not on any of its children
  137. if(event.target !== event.currentTarget) return;
  138. closeAttachmentViewer();
  139. },
  140. 'click #viewer-close'() {
  141. closeAttachmentViewer();
  142. },
  143. 'click #next-attachment'(event) {
  144. closeAttachmentViewer();
  145. let i = 0;
  146. // Find an attachment that can be opened
  147. while (true) {
  148. const id = getNextAttachmentId(openAttachmentId, i);
  149. const attachment = ReactiveCache.getAttachment(id);
  150. if (attachmentCanBeOpened(attachment)) {
  151. openAttachmentId = id;
  152. openAttachmentViewer(id);
  153. break;
  154. }
  155. i++;
  156. }
  157. },
  158. 'click #prev-attachment'(event) {
  159. closeAttachmentViewer();
  160. let i = 0;
  161. // Find an attachment that can be opened
  162. while (true) {
  163. const id = getPrevAttachmentId(openAttachmentId, i);
  164. const attachment = ReactiveCache.getAttachment(id);
  165. if (attachmentCanBeOpened(attachment)) {
  166. openAttachmentId = id;
  167. openAttachmentViewer(id);
  168. break;
  169. }
  170. i--;
  171. }
  172. }
  173. });
  174. Template.attachmentGallery.helpers({
  175. isBoardAdmin() {
  176. return ReactiveCache.getCurrentUser().isBoardAdmin();
  177. },
  178. fileSize(size) {
  179. const ret = filesize(size);
  180. return ret;
  181. },
  182. sanitize(value) {
  183. return DOMPurify.sanitize(value);
  184. },
  185. });
  186. Template.cardAttachmentsPopup.onCreated(function() {
  187. this.uploads = new ReactiveVar([]);
  188. });
  189. Template.cardAttachmentsPopup.helpers({
  190. getEstimateTime(upload) {
  191. const ret = prettyMilliseconds(upload.estimateTime.get());
  192. return ret;
  193. },
  194. getEstimateSpeed(upload) {
  195. const ret = filesize(upload.estimateSpeed.get(), {round: 0}) + "/s";
  196. return ret;
  197. },
  198. uploads() {
  199. return Template.instance().uploads.get();
  200. }
  201. });
  202. Template.cardAttachmentsPopup.events({
  203. 'change .js-attach-file'(event, templateInstance) {
  204. const card = this;
  205. const files = event.currentTarget.files;
  206. if (files) {
  207. let uploads = [];
  208. for (const file of files) {
  209. const fileId = new ObjectID().toString();
  210. // If filename is not same as sanitized filename, has XSS, then cancel upload
  211. if (file.name !== DOMPurify.sanitize(file.name)) {
  212. return false;
  213. }
  214. const config = {
  215. file: file,
  216. fileId: fileId,
  217. meta: Utils.getCommonAttachmentMetaFrom(card),
  218. chunkSize: 'dynamic',
  219. };
  220. config.meta.fileId = fileId;
  221. const uploader = Attachments.insert(
  222. config,
  223. false,
  224. );
  225. uploader.on('start', function() {
  226. uploads.push(this);
  227. templateInstance.uploads.set(uploads);
  228. });
  229. uploader.on('uploaded', (error, fileRef) => {
  230. if (!error) {
  231. if (fileRef.isImage) {
  232. card.setCover(fileRef._id);
  233. }
  234. }
  235. });
  236. uploader.on('end', (error, fileRef) => {
  237. uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);
  238. templateInstance.uploads.set(uploads);
  239. if (uploads.length == 0 ) {
  240. Popup.back();
  241. }
  242. });
  243. uploader.start();
  244. }
  245. }
  246. },
  247. 'click .js-computer-upload'(event, templateInstance) {
  248. templateInstance.find('.js-attach-file').click();
  249. event.preventDefault();
  250. },
  251. 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
  252. });
  253. const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
  254. const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
  255. let pastedResults = null;
  256. Template.previewClipboardImagePopup.onRendered(() => {
  257. // we can paste image from clipboard
  258. const handle = results => {
  259. if (results.dataURL.startsWith('data:image/')) {
  260. const direct = results => {
  261. $('img.preview-clipboard-image').attr('src', results.dataURL);
  262. pastedResults = results;
  263. };
  264. if (MAX_IMAGE_PIXEL) {
  265. // if has size limitation on image we shrink it before uploading
  266. Utils.shrinkImage({
  267. dataurl: results.dataURL,
  268. maxSize: MAX_IMAGE_PIXEL,
  269. ratio: COMPRESS_RATIO,
  270. callback(changed) {
  271. if (changed !== false && !!changed) {
  272. results.dataURL = changed;
  273. }
  274. direct(results);
  275. },
  276. });
  277. } else {
  278. direct(results);
  279. }
  280. }
  281. };
  282. $(document.body).pasteImageReader(handle);
  283. // we can also drag & drop image file to it
  284. $(document.body).dropImageReader(handle);
  285. });
  286. Template.previewClipboardImagePopup.events({
  287. 'click .js-upload-pasted-image'() {
  288. const card = this;
  289. if (pastedResults && pastedResults.file) {
  290. const file = pastedResults.file;
  291. window.oPasted = pastedResults;
  292. const fileId = new ObjectID().toString();
  293. const config = {
  294. file,
  295. fileId: fileId,
  296. meta: Utils.getCommonAttachmentMetaFrom(card),
  297. fileName: file.name || file.type.replace('image/', 'clipboard.'),
  298. chunkSize: 'dynamic',
  299. };
  300. config.meta.fileId = fileId;
  301. const uploader = Attachments.insert(
  302. config,
  303. false,
  304. );
  305. uploader.on('uploaded', (error, fileRef) => {
  306. if (!error) {
  307. if (fileRef.isImage) {
  308. card.setCover(fileRef._id);
  309. }
  310. }
  311. });
  312. uploader.on('end', (error, fileRef) => {
  313. pastedResults = null;
  314. $(document.body).pasteImageReader(() => {});
  315. Popup.back();
  316. });
  317. uploader.start();
  318. }
  319. },
  320. });
  321. BlazeComponent.extendComponent({
  322. isCover() {
  323. const ret = ReactiveCache.getCard(this.data().meta.cardId).coverId == this.data()._id;
  324. return ret;
  325. },
  326. isBackgroundImage() {
  327. //const currentBoard = Utils.getCurrentBoard();
  328. //return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");
  329. return false;
  330. },
  331. events() {
  332. return [
  333. {
  334. 'click .js-add-cover'() {
  335. ReactiveCache.getCard(this.data().meta.cardId).setCover(this.data()._id);
  336. Popup.back();
  337. },
  338. 'click .js-remove-cover'() {
  339. ReactiveCache.getCard(this.data().meta.cardId).unsetCover();
  340. Popup.back();
  341. },
  342. 'click .js-add-background-image'() {
  343. const currentBoard = Utils.getCurrentBoard();
  344. currentBoard.setBackgroundImageURL(attachmentActionsLink);
  345. Utils.setBackgroundImage(attachmentActionsLink);
  346. Popup.back();
  347. event.preventDefault();
  348. },
  349. 'click .js-remove-background-image'() {
  350. const currentBoard = Utils.getCurrentBoard();
  351. currentBoard.setBackgroundImageURL("");
  352. Utils.setBackgroundImage("");
  353. Popup.back();
  354. Utils.reload();
  355. event.preventDefault();
  356. },
  357. 'click .js-move-storage-fs'() {
  358. Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");
  359. Popup.back();
  360. },
  361. 'click .js-move-storage-gridfs'() {
  362. Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");
  363. Popup.back();
  364. },
  365. 'click .js-move-storage-s3'() {
  366. Meteor.call('moveAttachmentToStorage', this.data()._id, "s3");
  367. Popup.back();
  368. },
  369. }
  370. ]
  371. }
  372. }).register('attachmentActionsPopup');
  373. BlazeComponent.extendComponent({
  374. getNameWithoutExtension() {
  375. const ret = this.data().name.replace(new RegExp("\." + this.data().extension + "$"), "");
  376. return ret;
  377. },
  378. events() {
  379. return [
  380. {
  381. 'keydown input.js-edit-attachment-name'(evt) {
  382. // enter = save
  383. if (evt.keyCode === 13) {
  384. this.find('button[type=submit]').click();
  385. }
  386. },
  387. 'click button.js-submit-edit-attachment-name'(event) {
  388. // save button pressed
  389. event.preventDefault();
  390. const name = this.$('.js-edit-attachment-name')[0]
  391. .value
  392. .trim() + this.data().extensionWithDot;
  393. if (name === DOMPurify.sanitize(name)) {
  394. Meteor.call('renameAttachment', this.data()._id, name);
  395. }
  396. Popup.back(2);
  397. },
  398. }
  399. ]
  400. }
  401. }).register('attachmentRenamePopup');