attachments.js 12 KB

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