| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578 | import { ReactiveCache } from '/imports/reactiveCache';import { ObjectID } from 'bson';import DOMPurify from 'dompurify';import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';import uploadProgressManager from '../../lib/uploadProgressManager';const filesize = require('filesize');const prettyMilliseconds = require('pretty-ms');// We store current card ID and the ID of currently opened attachment in a// global var. This is used so that we know what's the next attachment to open// when the user clicks on the prev/next button in the attachment viewer.let cardId = null;let openAttachmentId = null;// Used to store the start and end coordinates of a touch event for attachment swipinglet touchStartCoords = null;let touchEndCoords = null;// Stores link to the attachment for which attachment actions popup was openedattachmentActionsLink = null;Template.attachmentGallery.events({  'click .open-preview'(event) {    openAttachmentId = $(event.currentTarget).attr("data-attachment-id");    cardId = $(event.currentTarget).attr("data-card-id");    openAttachmentViewer(openAttachmentId);  },  'click .js-add-attachment': Popup.open('cardAttachments'),  // If we let this event bubble, FlowRouter will handle it and empty the page  // content, see #101.  'click .js-download'(event) {    event.stopPropagation();  },  'click .js-open-attachment-menu': Popup.open('attachmentActions'),  '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.    attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");  },  'click .js-rename': Popup.open('attachmentRename'),  'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {      Attachments.remove(this._id);      Popup.back();  }),});function getNextAttachmentId(currentAttachmentId, offset = 0) {    const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});  let i = 0;  for (; i < attachments.length; i++) {    if (attachments[i]._id === currentAttachmentId) {      break;    }  }  return attachments[(i + offset + 1 + attachments.length) % attachments.length]._id;}function getPrevAttachmentId(currentAttachmentId, offset = 0) {  const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});  let i = 0;  for (; i < attachments.length; i++) {    if (attachments[i]._id === currentAttachmentId) {      break;    }  }  return attachments[(i + offset - 1 + attachments.length) % attachments.length]._id;}function attachmentCanBeOpened(attachment) {  return (    attachment.isImage ||    attachment.isPDF ||    attachment.isText ||    attachment.isJSON ||    attachment.isVideo ||    attachment.isAudio  );}function openAttachmentViewer(attachmentId) {  const attachment = ReactiveCache.getAttachment(attachmentId);  // Check if we can open the attachment (if we have a viewer for it) and exit if not  if (!attachmentCanBeOpened(attachment)) {    return;  }  /*  Instructions for adding a new viewer:    - add a new case to the switch statement below    - implement cleanup in the closeAttachmentViewer() function, if necessary    - mark attachment type as openable by adding a new condition to the attachmentCanBeOpened function  */  switch(true){    case (attachment.isImage):      $("#image-viewer").attr("src", attachment.link());      $("#image-viewer").removeClass("hidden");      break;    case (attachment.isPDF):      $("#pdf-viewer").attr("data", attachment.link());      $("#pdf-viewer").removeClass("hidden");      break;    case (attachment.isVideo):      // We have to create a new <source> DOM element and append it to the video      // element, otherwise the video won't load      let videoSource = document.createElement('source');      videoSource.setAttribute('src', attachment.link());      $("#video-viewer").append(videoSource);      $("#video-viewer").removeClass("hidden");      break;    case (attachment.isAudio):      // We have to create a new <source> DOM element and append it to the audio      // element, otherwise the audio won't load      let audioSource = document.createElement('source');      audioSource.setAttribute('src', attachment.link());      $("#audio-viewer").append(audioSource);      $("#audio-viewer").removeClass("hidden");      break;    case (attachment.isText):    case (attachment.isJSON):      $("#txt-viewer").attr("data", attachment.link());      $("#txt-viewer").removeClass("hidden");      break;  }  $('#attachment-name').text(attachment.name);  $('#viewer-overlay').removeClass('hidden');}function closeAttachmentViewer() {  $("#viewer-overlay").addClass("hidden");  // We need to reset the viewers to avoid showing previous attachments  $("#image-viewer").attr("src", "");  $("#image-viewer").addClass("hidden");  $("#pdf-viewer").attr("data", "");  $("#pdf-viewer").addClass("hidden");  $("#txt-viewer").attr("data", "");  $("#txt-viewer").addClass("hidden");  $("#video-viewer").get(0).pause(); // Stop playback  $("#video-viewer").get(0).currentTime = 0;  $("#video-viewer").empty();  $("#video-viewer").addClass("hidden");  $("#audio-viewer").get(0).pause(); // Stop playback  $("#audio-viewer").get(0).currentTime = 0;  $("#audio-viewer").empty();  $("#audio-viewer").addClass("hidden");}function openNextAttachment() {  closeAttachmentViewer();    let i = 0;    // Find an attachment that can be opened    while (true) {      const id = getNextAttachmentId(openAttachmentId, i);      const attachment = ReactiveCache.getAttachment(id);      if (attachmentCanBeOpened(attachment)) {        openAttachmentId = id;        openAttachmentViewer(id);        break;      }      i++;    }}function openPrevAttachment() {  closeAttachmentViewer();    let i = 0;    // Find an attachment that can be opened    while (true) {      const id = getPrevAttachmentId(openAttachmentId, i);      const attachment = ReactiveCache.getAttachment(id);      if (attachmentCanBeOpened(attachment)) {        openAttachmentId = id;        openAttachmentViewer(id);        break;      }      i--;    }}function processTouch(){  xDist = touchEndCoords.x - touchStartCoords.x;  yDist = touchEndCoords.y - touchStartCoords.y;  console.log("xDist: " + xDist);  // Left swipe  if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {    openNextAttachment();  }  // Right swipe  if (Math.abs(xDist) > Math.abs(yDist) && xDist > 0) {    openPrevAttachment();  }  // Up swipe  if (Math.abs(yDist) > Math.abs(xDist) && yDist < 0) {    closeAttachmentViewer();  }}Template.attachmentViewer.events({  'touchstart #viewer-container'(event) {    console.log("touchstart")    touchStartCoords = {      x: event.changedTouches[0].screenX,      y: event.changedTouches[0].screenY    }  },  'touchend #viewer-container'(event) {    console.log("touchend")    touchEndCoords = {      x: event.changedTouches[0].screenX,      y: event.changedTouches[0].screenY    }    processTouch();  },  'click #viewer-container'(event) {    // Make sure the click was on #viewer-container and not on any of its children    if(event.target !== event.currentTarget) {      event.stopPropagation();      return;    }    closeAttachmentViewer();  },  'click #viewer-content'(event) {    // Make sure the click was on #viewer-content and not on any of its children    if(event.target !== event.currentTarget) {      event.stopPropagation();      return;    }    closeAttachmentViewer();  },  'click #viewer-close'() {    closeAttachmentViewer();  },  'click #next-attachment'() {    openNextAttachment();  },  'click #prev-attachment'() {    openPrevAttachment();  },});Template.attachmentGallery.helpers({  isBoardAdmin() {    return ReactiveCache.getCurrentUser().isBoardAdmin();  },  fileSize(size) {    const ret = filesize(size);    return ret;  },  sanitize(value) {    return sanitizeHTML(value);  },});Template.cardAttachmentsPopup.onCreated(function() {  this.uploads = new ReactiveVar([]);});Template.cardAttachmentsPopup.helpers({  getEstimateTime(upload) {    const ret = prettyMilliseconds(upload.estimateTime.get());    return ret;  },  getEstimateSpeed(upload) {    const ret = filesize(upload.estimateSpeed.get(), {round: 0}) + "/s";    return ret;  },  uploads() {    return Template.instance().uploads.get();  }});Template.cardAttachmentsPopup.events({  'change .js-attach-file'(event, templateInstance) {    const card = this;    const files = event.currentTarget.files;    if (files) {      let uploads = [];      const uploaders = handleFileUpload(card, files);      uploaders.forEach(uploader => {        uploader.on('start', function() {          uploads.push(this);          templateInstance.uploads.set(uploads);        });        uploader.on('end', (error, fileRef) => {          uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);          templateInstance.uploads.set(uploads);          if (uploads.length == 0 ) {            Popup.back();          }        });      });    }  },  'click .js-computer-upload'(event, templateInstance) {    templateInstance.find('.js-attach-file').click();    event.preventDefault();  },  'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),});const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;let pastedResults = null;// Shared upload logic for drag-and-drop functionalityexport function handleFileUpload(card, files) {  if (!files || files.length === 0) {    return [];  }  // Check if board allows attachments  const board = card.board();  if (!board || !board.allowsAttachments) {    if (process.env.DEBUG === 'true') {      console.warn('Attachments not allowed on this board');    }    return [];  }  // Check if user can modify the card  if (!card.canModifyCard()) {    if (process.env.DEBUG === 'true') {      console.warn('User does not have permission to modify this card');    }    return [];  }  const uploads = [];  for (const file of files) {    // Basic file validation    if (!file || !file.name) {      if (process.env.DEBUG === 'true') {        console.warn('Invalid file object');      }      continue;    }    const fileId = new ObjectID().toString();    let fileName = sanitizeText(file.name);    // If sanitized filename is not same as original filename,    // it could be XSS that is already fixed with sanitize,    // or just normal mistake, so it is not a problem.    // That is why here is no warning.    if (fileName !== file.name) {      // If filename is empty, only in that case add some filename      if (fileName.length === 0) {        fileName = 'Empty-filename-after-sanitize.txt';      }    }    const config = {      file: file,      fileId: fileId,      fileName: fileName,      meta: Utils.getCommonAttachmentMetaFrom(card),      chunkSize: 'dynamic',    };    config.meta.fileId = fileId;    try {      const uploader = Attachments.insert(        config,        false,      );      // Add to progress manager for tracking      const uploadId = uploadProgressManager.addUpload(card._id, uploader, file);      uploader.on('uploaded', (error, fileRef) => {        if (!error) {          if (fileRef.isImage) {            card.setCover(fileRef._id);            if (process.env.DEBUG === 'true') {              console.log(`Set cover image for card ${card._id}: ${fileRef.name}`);            }          }        } else {          if (process.env.DEBUG === 'true') {            console.error('Upload error:', error);          }        }      });      uploader.on('error', (error) => {        if (process.env.DEBUG === 'true') {          console.error('Upload error:', error);        }      });      uploads.push(uploader);      uploader.start();    } catch (error) {      if (process.env.DEBUG === 'true') {        console.error('Failed to create uploader:', error);      }    }  }  return uploads;}Template.previewClipboardImagePopup.onRendered(() => {  // we can paste image from clipboard  const handle = results => {    if (results.dataURL.startsWith('data:image/')) {      const direct = results => {        $('img.preview-clipboard-image').attr('src', results.dataURL);        pastedResults = results;      };      if (MAX_IMAGE_PIXEL) {        // if has size limitation on image we shrink it before uploading        Utils.shrinkImage({          dataurl: results.dataURL,          maxSize: MAX_IMAGE_PIXEL,          ratio: COMPRESS_RATIO,          callback(changed) {            if (changed !== false && !!changed) {              results.dataURL = changed;            }            direct(results);          },        });      } else {        direct(results);      }    }  };  $(document.body).pasteImageReader(handle);  // we can also drag & drop image file to it  $(document.body).dropImageReader(handle);});Template.previewClipboardImagePopup.events({  'click .js-upload-pasted-image'() {    const card = this;    if (pastedResults && pastedResults.file) {      const file = pastedResults.file;      window.oPasted = pastedResults;      const fileId = new ObjectID().toString();      const config = {        file,        fileId: fileId,        meta: Utils.getCommonAttachmentMetaFrom(card),        fileName: file.name || file.type.replace('image/', 'clipboard.'),        chunkSize: 'dynamic',      };      config.meta.fileId = fileId;      const uploader = Attachments.insert(        config,        false,      );      uploader.on('uploaded', (error, fileRef) => {        if (!error) {          if (fileRef.isImage) {            card.setCover(fileRef._id);          }        }      });      uploader.on('end', (error, fileRef) => {        pastedResults = null;        $(document.body).pasteImageReader(() => {});        Popup.back();      });      uploader.start();    }  },});BlazeComponent.extendComponent({  isCover() {    const ret = ReactiveCache.getCard(this.data().meta.cardId).coverId == this.data()._id;    return ret;  },  isBackgroundImage() {    //const currentBoard = Utils.getCurrentBoard();    //return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");    return false;  },  events() {    return [      {        'click .js-add-cover'() {          ReactiveCache.getCard(this.data().meta.cardId).setCover(this.data()._id);          Popup.back();        },        'click .js-remove-cover'() {          ReactiveCache.getCard(this.data().meta.cardId).unsetCover();          Popup.back();        },        'click .js-add-background-image'() {          const currentBoard = Utils.getCurrentBoard();          currentBoard.setBackgroundImageURL(attachmentActionsLink);          Utils.setBackgroundImage(attachmentActionsLink);          Popup.back();          event.preventDefault();        },        'click .js-remove-background-image'() {          const currentBoard = Utils.getCurrentBoard();          currentBoard.setBackgroundImageURL("");          Utils.setBackgroundImage("");          Popup.back();          Utils.reload();          event.preventDefault();        },        'click .js-move-storage-fs'() {          Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");          Popup.back();        },        'click .js-move-storage-gridfs'() {          Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");          Popup.back();        },        'click .js-move-storage-s3'() {          Meteor.call('moveAttachmentToStorage', this.data()._id, "s3");          Popup.back();        },      }    ]  }}).register('attachmentActionsPopup');BlazeComponent.extendComponent({  getNameWithoutExtension() {    const ret = this.data().name.replace(new RegExp("\." + this.data().extension + "$"), "");    return ret;  },  events() {    return [      {        'keydown input.js-edit-attachment-name'(evt) {          // enter = save          if (evt.keyCode === 13) {            this.find('button[type=submit]').click();          }        },        'click button.js-submit-edit-attachment-name'(event) {          // save button pressed          event.preventDefault();          const name = this.$('.js-edit-attachment-name')[0]            .value            .trim() + this.data().extensionWithDot;          if (name === sanitizeText(name)) {            Meteor.call('renameAttachment', this.data()._id, name);          }          Popup.back();        },      }    ]  }}).register('attachmentRenamePopup');
 |