Prechádzať zdrojové kódy

Multi-File Storage.

Thanks to mfilser !

Related https://github.com/wekan/wekan/pull/4484

Merge branch 'master' into upgrade-meteor
Lauri Ojansivu 3 rokov pred
rodič
commit
68e8155805

+ 1 - 1
.gitignore

@@ -1,5 +1,5 @@
 *~
-*.swp
+*.sw*
 .meteor-spk
 *.sublime-workspace
 tmp/

+ 2 - 0
CHANGELOG.md

@@ -14,6 +14,8 @@ This release adds the following new features:
   Thanks to xet7.
 - [Added Table View to My Cards](https://github.com/wekan/wekan/pulls/4479).
   Thanks to helioguardabaxo.
+- [Multi file storage for moving between MongoDB GridFS and filesystem](https://github.com/wekan/wekan/pull/4484).
+  Thanks to mfilser.
 
 and adds the following updates:
 

+ 1 - 1
README.md

@@ -57,7 +57,7 @@ that by providing one-click installation on various platforms.
 
 - WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
 - Wekan largest user has 22k users using Wekan in their company.
-- Wekan has been [translated](https://transifex.com/wekan/wekan) to about 70 languages.
+- Wekan has been [translated](https://transifex.com/wekan/wekan) to about 105 languages.
 - [Features][features]: WeKan ® has real-time user interface.
 - [Platforms][platforms]: WeKan ® supports many platforms.
   WeKan ® is critical part of new platforms Wekan is currently being integrated to.

+ 29 - 11
client/components/cards/attachments.jade

@@ -49,17 +49,7 @@ template(name="attachmentsGalery")
             if currentUser.isBoardMember
               unless currentUser.isCommentOnly
                 unless currentUser.isWorker
-                  if isImage
-                    a(class="{{#if $eq ../coverId _id}}js-remove-cover{{else}}js-add-cover{{/if}}")
-                      i.fa.fa-thumb-tack
-                      if($eq ../coverId _id)
-                        | {{_ 'remove-cover'}}
-                      else
-                        | {{_ 'add-cover'}}
-                  if currentUser.isBoardAdmin
-                    a.js-confirm-delete
-                      i.fa.fa-close
-                      | {{_ 'delete'}}
+                  a.fa.fa-navicon.attachment-details-menu.js-open-attachment-menu(title="{{_ 'attachmentActionsPopup-title'}}")
 
     if currentUser.isBoardMember
       unless currentUser.isCommentOnly
@@ -67,3 +57,31 @@ template(name="attachmentsGalery")
           //li.attachment-item.add-attachment
           a.js-add-attachment(title="{{_ 'add-attachment' }}")
             i.fa.fa-plus
+
+template(name="attachmentActionsPopup")
+  ul.pop-over-list
+    li
+      if isImage
+        a(class="{{#if isCover}}js-remove-cover{{else}}js-add-cover{{/if}}")
+          i.fa.fa-thumb-tack
+          if isCover
+            | {{_ 'remove-cover'}}
+          else
+            | {{_ 'add-cover'}}
+      if currentUser.isBoardAdmin
+        a.js-confirm-delete
+          i.fa.fa-close
+          | {{_ 'delete'}}
+        p.attachment-storage
+          | {{versions.original.storage}}
+
+        if $neq versions.original.storage "fs"
+          a.js-move-storage-fs
+            i.fa.fa-arrow-right
+            | {{_ 'attachment-move-storage-fs'}}
+
+        if $neq versions.original.storage "gridfs"
+          if versions.original.storage
+            a.js-move-storage-gridfs
+              i.fa.fa-arrow-right
+              | {{_ 'attachment-move-storage-gridfs'}}

+ 53 - 24
client/components/cards/attachments.js

@@ -1,23 +1,11 @@
 Template.attachmentsGalery.events({
   'click .js-add-attachment': Popup.open('cardAttachments'),
-  'click .js-confirm-delete': Popup.afterConfirm(
-    'attachmentDelete',
-    function() {
-      Attachments.remove(this._id);
-      Popup.back();
-    },
-  ),
   // 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-add-cover'() {
-    Cards.findOne(this.meta.cardId).setCover(this._id);
-  },
-  'click .js-remove-cover'() {
-    Cards.findOne(this.meta.cardId).unsetCover();
-  },
+  'click .js-open-attachment-menu': Popup.open('attachmentActions'),
 });
 
 Template.attachmentsGalery.helpers({
@@ -33,12 +21,16 @@ Template.cardAttachmentsPopup.events({
   'change .js-attach-file'(event) {
     const card = this;
     if (event.currentTarget.files && event.currentTarget.files[0]) {
+      const fileId = Random.id();
+      const config = {
+        file: event.currentTarget.files[0],
+        fileId: fileId,
+        meta: Utils.getCommonAttachmentMetaFrom(card),
+        chunkSize: 'dynamic',
+      };
+      config.meta.fileId = fileId;
       const uploader = Attachments.insert(
-        {
-          file: event.currentTarget.files[0],
-          meta: Utils.getCommonAttachmentMetaFrom(card),
-          chunkSize: 'dynamic',
-        },
+        config,
         false,
       );
       uploader.on('uploaded', (error, fileRef) => {
@@ -104,13 +96,17 @@ Template.previewClipboardImagePopup.events({
     if (pastedResults && pastedResults.file) {
       const file = pastedResults.file;
       window.oPasted = pastedResults;
+      const fileId = Random.id();
+      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(
-        {
-          file,
-          meta: Utils.getCommonAttachmentMetaFrom(card),
-          fileName: file.name || file.type.replace('image/', 'clipboard.'),
-          chunkSize: 'dynamic',
-        },
+        config,
         false,
       );
       uploader.on('uploaded', (error, fileRef) => {
@@ -129,3 +125,36 @@ Template.previewClipboardImagePopup.events({
     }
   },
 });
+
+BlazeComponent.extendComponent({
+  isCover() {
+    const ret = Cards.findOne(this.data().meta.cardId).coverId == this.data()._id;
+    return ret;
+  },
+  events() {
+    return [
+      {
+        'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {
+          Attachments.remove(this._id);
+          Popup.back(2);
+        }),
+        'click .js-add-cover'() {
+          Cards.findOne(this.data().meta.cardId).setCover(this.data()._id);
+          Popup.back();
+        },
+        'click .js-remove-cover'() {
+          Cards.findOne(this.data().meta.cardId).unsetCover();
+          Popup.back();
+        },
+        '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();
+        },
+      }
+    ]
+  }
+}).register('attachmentActionsPopup');

+ 3 - 0
client/components/cards/attachments.styl

@@ -46,6 +46,9 @@
       .attachment-details-actions a
         display: block
 
+        &.attachment-details-menu
+          padding-top: 10px
+
 .attachment-image-preview
   max-width: 100px
   display: block

+ 11 - 39
client/components/settings/adminReports.jade

@@ -11,11 +11,6 @@ template(name="adminReports")
                 i.fa.fa-chain-broken
                 | {{_ 'broken-cards'}}
 
-            li
-              a.js-report-files(data-id="report-orphaned-files")
-                i.fa.fa-paperclip
-                | {{_ 'orphanedFilesReportTitle'}}
-
             li
               a.js-report-files(data-id="report-files")
                 i.fa.fa-paperclip
@@ -43,8 +38,6 @@ template(name="adminReports")
             +brokenCardsReport
           else if showFilesReport.get
             +filesReport
-          else if showOrphanedFilesReport.get
-            +orphanedFilesReport
           else if showRulesReport.get
             +rulesReport
           else if showBoardsReport.get
@@ -64,7 +57,7 @@ template(name="brokenCardsReport")
 template(name="rulesReport")
   h1 {{_ 'rulesReportTitle'}}
   if resultsCount
-    table.table
+    table
       tr
         th Rule Title
         th Board Title
@@ -83,44 +76,23 @@ template(name="rulesReport")
 template(name="filesReport")
   h1 {{_ 'filesReportTitle'}}
   if resultsCount
-    table.table
-      tr
-        th Filename
-        th.right Size (kB)
-        th MIME Type
-        th.center Usage
-        th MD5 Sum
-        th ID
-
-      each att in results
-        tr
-          td {{ att.filename }}
-          td.right {{fileSize att.length }}
-          td {{ att.contentType }}
-          td.center {{usageCount att._id.toHexString }}
-          td {{ att.md5 }}
-          td {{ att._id.toHexString }}
-  else
-    div {{_ 'no-results' }}
-
-template(name="orphanedFilesReport")
-  h1 {{_ 'orphanedFilesReportTitle'}}
-  if resultsCount
-    table.table
+    table
       tr
         th Filename
         th.right Size (kB)
         th MIME Type
-        th MD5 Sum
-        th ID
+        th Attachment ID
+        th Board ID
+        th Card ID
 
       each att in results
         tr
-          td {{ att.filename }}
-          td.right {{fileSize att.length }}
-          td {{ att.contentType }}
-          td {{ att.md5 }}
-          td {{ att._id.toHexString }}
+          td {{ att.name }}
+          td.right {{fileSize att.size }}
+          td {{ att.type }}
+          td {{ att._id }}
+          td {{ att.meta.boardId }}
+          td {{ att.meta.cardId }}
   else
     div {{_ 'no-results' }}
 

+ 1 - 17
client/components/settings/adminReports.js

@@ -26,7 +26,6 @@ BlazeComponent.extendComponent({
       {
         'click a.js-report-broken': this.switchMenu,
         'click a.js-report-files': this.switchMenu,
-        'click a.js-report-orphaned-files': this.switchMenu,
         'click a.js-report-rules': this.switchMenu,
         'click a.js-report-cards': this.switchMenu,
         'click a.js-report-boards': this.switchMenu,
@@ -66,11 +65,6 @@ BlazeComponent.extendComponent({
         this.subscription = Meteor.subscribe('attachmentsList', () => {
           this.loading.set(false);
         });
-      } else if ('report-orphaned-files' === targetID) {
-        this.showOrphanedFilesReport.set(true);
-        this.subscription = Meteor.subscribe('orphanedAttachments', () => {
-          this.loading.set(false);
-        });
       } else if ('report-rules' === targetID) {
         this.subscription = Meteor.subscribe('rulesReport', () => {
           this.showRulesReport.set(true);
@@ -104,8 +98,6 @@ class AdminReport extends BlazeComponent {
 
   results() {
     // eslint-disable-next-line no-console
-    // console.log('attachments:', AttachmentStorage.find());
-    // console.log('attachments.count:', AttachmentStorage.find().count());
     return this.collection.find();
   }
 
@@ -125,10 +117,6 @@ class AdminReport extends BlazeComponent {
     return Math.round(size / 1024);
   }
 
-  usageCount(key) {
-    return Attachments.find({ 'copies.attachments.key': key }).count();
-  }
-
   abbreviate(text) {
     if (text.length > 30) {
       return `${text.substr(0, 29)}...`;
@@ -138,13 +126,9 @@ class AdminReport extends BlazeComponent {
 }
 
 (class extends AdminReport {
-  collection = AttachmentStorage;
+  collection = Attachments;
 }.register('filesReport'));
 
-(class extends AdminReport {
-  collection = AttachmentStorage;
-}.register('orphanedFilesReport'));
-
 (class extends AdminReport {
   collection = Rules;
 

+ 0 - 3
client/components/settings/adminReports.styl

@@ -1,3 +0,0 @@
-.admin-reports-content
-  height: auto !important
-

+ 84 - 0
client/components/settings/attachments.jade

@@ -0,0 +1,84 @@
+template(name="attachments")
+  .setting-content.attachments-content
+    unless currentUser.isAdmin
+      | {{_ 'error-notAuthorized'}}
+    else
+      .content-body
+        .side-menu
+          ul
+            li
+              a.js-move-attachments(data-id="move-attachments")
+                i.fa.fa-arrow-right
+                | {{_ 'attachment-move'}}
+
+        .main-body
+          if loading.get
+            +spinner
+          else if showMoveAttachments.get
+            +moveAttachments
+
+template(name="moveAttachments")
+  .move-attachment-buttons
+    .js-move-attachment
+      button.js-move-all-attachments-to-fs {{_ 'move-all-attachments-to-fs'}}
+    .js-move-attachment
+      button.js-move-all-attachments-to-gridfs {{_ 'move-all-attachments-to-gridfs'}}
+
+  each board in getBoardsWithAttachments
+    +moveBoardAttachments board
+
+template(name="moveBoardAttachments")
+  hr
+  .board-description
+    table
+      tr
+        th {{_ 'board'}} ID
+        th {{_ 'board-title'}}
+      tr
+        td {{ _id }}
+        td {{ title }}
+
+  .move-attachment-buttons
+    .js-move-attachment
+      button.js-move-all-attachments-of-board-to-fs {{_ 'move-all-attachments-of-board-to-fs'}}
+    .js-move-attachment
+      button.js-move-all-attachments-of-board-to-gridfs {{_ 'move-all-attachments-of-board-to-gridfs'}}
+
+  .board-attachments
+    table
+      tr
+        th {{_ 'card'}}-Id
+        th {{_ 'attachment'}}-Id
+        th {{_ 'name'}}
+        th {{_ 'path'}}
+        th {{_ 'version-name'}}
+        th {{_ 'size'}} (B)
+        th GridFsFileId
+        th {{_ 'storage'}}
+        th {{_ 'action'}}
+
+      each attachment in attachments
+        +moveAttachment attachment
+
+template(name="moveAttachment")
+  each version in flatVersion
+    tr
+      td {{ meta.cardId }}
+      td {{ _id }}
+      td {{ name }}
+      td {{ version.path }}
+      td {{ version.versionName }}
+      td {{ version.size }}
+      td {{ version.meta.gridFsFileId }}
+      td {{ version.storageName }}
+      td
+        if $neq version.storageName "fs"
+          button.js-move-storage-fs
+            i.fa.fa-arrow-right
+            | {{_ 'attachment-move-storage-fs'}}
+
+        if $neq version.storageName "gridfs"
+          if version.storageName
+            button.js-move-storage-gridfs
+              i.fa.fa-arrow-right
+              | {{_ 'attachment-move-storage-gridfs'}}

+ 123 - 0
client/components/settings/attachments.js

@@ -0,0 +1,123 @@
+import Attachments, { fileStoreStrategyFactory } from '/models/attachments';
+
+BlazeComponent.extendComponent({
+  subscription: null,
+  showMoveAttachments: new ReactiveVar(false),
+  sessionId: null,
+
+  onCreated() {
+    this.error = new ReactiveVar('');
+    this.loading = new ReactiveVar(false);
+  },
+
+  events() {
+    return [
+      {
+        'click a.js-move-attachments': this.switchMenu,
+      },
+    ];
+  },
+
+  switchMenu(event) {
+    const target = $(event.target);
+    if (!target.hasClass('active')) {
+      this.loading.set(true);
+      this.showMoveAttachments.set(false);
+      if (this.subscription) {
+        this.subscription.stop();
+      }
+
+      $('.side-menu li.active').removeClass('active');
+      target.parent().addClass('active');
+      const targetID = target.data('id');
+
+      if ('move-attachments' === targetID) {
+        this.showMoveAttachments.set(true);
+        this.subscription = Meteor.subscribe('attachmentsList', () => {
+          this.loading.set(false);
+        });
+      }
+    }
+  },
+}).register('attachments');
+
+BlazeComponent.extendComponent({
+  getBoardsWithAttachments() {
+    this.attachments = Attachments.find().get();
+    this.attachmentsByBoardId = _.chain(this.attachments)
+      .groupBy(fileObj => fileObj.meta.boardId)
+      .value();
+
+    const ret = Object.keys(this.attachmentsByBoardId)
+      .map(boardId => {
+        const boardAttachments = this.attachmentsByBoardId[boardId];
+
+        _.each(boardAttachments, _attachment => {
+          _attachment.flatVersion = Object.keys(_attachment.versions)
+            .map(_versionName => {
+              const _version = Object.assign(_attachment.versions[_versionName], {"versionName": _versionName});
+              _version.storageName = fileStoreStrategyFactory.getFileStrategy(_attachment, _versionName).getStorageName();
+              return _version;
+            });
+        });
+        const board = Boards.findOne(boardId);
+        board.attachments = boardAttachments;
+        return board;
+      })
+    return ret;
+  },
+  getBoardData(boardid) {
+    const ret = Boards.findOne(boardId);
+    return ret;
+  },
+  events() {
+    return [
+      {
+        'click button.js-move-all-attachments-to-fs'(event) {
+          this.attachments.forEach(_attachment => {
+            Meteor.call('moveAttachmentToStorage', _attachment._id, "fs");
+          });
+        },
+        'click button.js-move-all-attachments-to-gridfs'(event) {
+          this.attachments.forEach(_attachment => {
+            Meteor.call('moveAttachmentToStorage', _attachment._id, "gridfs");
+          });
+        },
+      }
+    ]
+  }
+}).register('moveAttachments');
+
+BlazeComponent.extendComponent({
+  events() {
+    return [
+      {
+        'click button.js-move-all-attachments-of-board-to-fs'(event) {
+          this.data().attachments.forEach(_attachment => {
+            Meteor.call('moveAttachmentToStorage', _attachment._id, "fs");
+          });
+        },
+        'click button.js-move-all-attachments-of-board-to-gridfs'(event) {
+          this.data().attachments.forEach(_attachment => {
+            Meteor.call('moveAttachmentToStorage', _attachment._id, "gridfs");
+          });
+        },
+      }
+    ]
+  },
+}).register('moveBoardAttachments');
+
+BlazeComponent.extendComponent({
+  events() {
+    return [
+      {
+        'click button.js-move-storage-fs'(event) {
+          Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");
+        },
+        'click button.js-move-storage-gridfs'(event) {
+          Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");
+        },
+      }
+    ]
+  },
+}).register('moveAttachment');

+ 8 - 0
client/components/settings/attachments.styl

@@ -0,0 +1,8 @@
+.move-attachment-buttons
+  display: flex
+  gap: 10px
+
+.attachments-content
+  hr
+    height: 0px
+    border: 1px solid black

+ 1 - 4
client/components/settings/peopleBody.styl

@@ -2,8 +2,6 @@
   overflow: scroll;
 
 table
-  border-collapse: collapse;
-  width: 100%;
   color: #000;
 
   td, th
@@ -22,14 +20,13 @@ table
   .ext-box-left
     display: flex;
     width: 100%
+    gap: 10px
 
   span
     vertical-align: center;
     line-height: 34px;
-    margin-right: 10px;
 
   input, button
-    margin: 0 10px 0 0;
     padding: 0;
 
   button

+ 1 - 3
client/components/settings/settingBody.styl

@@ -7,11 +7,9 @@
   display: flex
 
 .setting-content
-  padding 30px
   color: #727479
   background: #dedede
   width 100%
-  height calc(100% - 80px)
   position: absolute;
 
   .content-title
@@ -21,6 +19,7 @@
     display flex
     padding-top 15px
     height 100%
+    gap: 10px;
 
     .side-menu
       background-color: #f7f7f7;
@@ -54,7 +53,6 @@
               margin-right: 20px
 
     .main-body
-      padding: 0.1em 1em
       -webkit-user-select: text // Safari 3.1+
       -moz-user-select: text // Firefox 2+
       -ms-user-select: text // IE 10+

+ 4 - 0
client/components/settings/settingHeader.jade

@@ -16,6 +16,10 @@ template(name="settingHeaderBar")
         i.fa(class="fa-list")
         span {{_ 'reports'}}
 
+      a.setting-header-btn.informations(href="{{pathFor 'attachments'}}")
+        i.fa(class="fa-paperclip")
+        span {{_ 'attachments'}}
+
       a.setting-header-btn.informations(href="{{pathFor 'information'}}")
         i.fa(class="fa-info-circle")
         span {{_ 'info'}}

+ 24 - 0
config/router.js

@@ -357,6 +357,30 @@ FlowRouter.route('/admin-reports', {
   },
 });
 
+FlowRouter.route('/attachments', {
+  name: 'attachments',
+  triggersEnter: [
+    AccountsTemplates.ensureSignedIn,
+    () => {
+      Session.set('currentBoard', null);
+      Session.set('currentList', null);
+      Session.set('currentCard', null);
+      Session.set('popupCardId', null);
+      Session.set('popupCardBoardId', null);
+
+      Filter.reset();
+      Session.set('sortBy', '');
+      EscapeActions.executeAll();
+    },
+  ],
+  action() {
+    BlazeLayout.render('defaultLayout', {
+      headerBar: 'settingHeaderBar',
+      content: 'attachments',
+    });
+  },
+});
+
 FlowRouter.notFound = {
   action() {
     BlazeLayout.render('defaultLayout', { content: 'notFound' });

+ 15 - 2
imports/i18n/data/en.i18n.json

@@ -1086,7 +1086,6 @@
   "custom-field-stringtemplate-item-placeholder": "Press enter to add more items",
   "creator": "Creator",
   "filesReportTitle": "Files Report",
-  "orphanedFilesReportTitle": "Orphaned Files Report",
   "reports": "Reports",
   "rulesReportTitle": "Rules Report",
   "boardsReportTitle": "Boards Report",
@@ -1164,5 +1163,19 @@
   "copyChecklist": "Copy Checklist",
   "copyChecklistPopup-title": "Copy Checklist",
   "card-show-lists": "Card Show Lists",
-  "subtaskActionsPopup-title": "Subtask Actions"
+  "subtaskActionsPopup-title": "Subtask Actions",
+  "attachmentActionsPopup-title": "Attachment Actions",
+  "attachment-move-storage-fs": "Move attachment to filesystem",
+  "attachment-move-storage-gridfs": "Move attachment to GridFS",
+  "attachment-move": "Move Attachment",
+  "move-all-attachments-to-fs": "Move all attachments to filesystem",
+  "move-all-attachments-to-gridfs": "Move all attachments to GridFS",
+  "move-all-attachments-of-board-to-fs": "Move all attachments of board to filesystem",
+  "move-all-attachments-of-board-to-gridfs": "Move all attachments of board to GridFS",
+  "path": "Path",
+  "version-name": "Version-Name",
+  "size": "Size",
+  "storage": "Storage",
+  "action": "Action",
+  "board-title": "Board Title"
 }

+ 15 - 2
imports/i18n/data/ua-UA.i18n.json

@@ -1085,7 +1085,6 @@
     "custom-field-stringtemplate-item-placeholder": "Press enter to add more items",
     "creator": "Creator",
     "filesReportTitle": "Files Report",
-    "orphanedFilesReportTitle": "Orphaned Files Report",
     "reports": "Reports",
     "rulesReportTitle": "Rules Report",
     "boardsReportTitle": "Boards Report",
@@ -1163,5 +1162,19 @@
     "copyChecklist": "Copy Checklist",
     "copyChecklistPopup-title": "Copy Checklist",
     "card-show-lists": "Card Show Lists",
-    "subtaskActionsPopup-title": "Subtask Actions"
+    "subtaskActionsPopup-title": "Subtask Actions",
+    "attachmentActionsPopup-title": "Attachment Actions",
+    "attachment-move-storage-fs": "Move attachment to filesystem",
+    "attachment-move-storage-gridfs": "Move attachment to GridFS",
+    "attachment-move": "Move Attachment",
+    "move-all-attachments-to-fs": "Move all attachments to filesystem",
+    "move-all-attachments-to-gridfs": "Move all attachments to GridFS",
+    "move-all-attachments-of-board-to-fs": "Move all attachments of board to filesystem",
+    "move-all-attachments-of-board-to-gridfs": "Move all attachments of board to GridFS",
+    "path": "Path",
+    "version-name": "Version-Name",
+    "size": "Size",
+    "storage": "Storage",
+    "action": "Action",
+    "board-title": "Board Title"
 }

+ 15 - 2
imports/i18n/data/ua.i18n.json

@@ -1085,7 +1085,6 @@
     "custom-field-stringtemplate-item-placeholder": "Press enter to add more items",
     "creator": "Creator",
     "filesReportTitle": "Files Report",
-    "orphanedFilesReportTitle": "Orphaned Files Report",
     "reports": "Reports",
     "rulesReportTitle": "Rules Report",
     "boardsReportTitle": "Boards Report",
@@ -1163,5 +1162,19 @@
     "copyChecklist": "Copy Checklist",
     "copyChecklistPopup-title": "Copy Checklist",
     "card-show-lists": "Card Show Lists",
-    "subtaskActionsPopup-title": "Subtask Actions"
+    "subtaskActionsPopup-title": "Subtask Actions",
+    "attachmentActionsPopup-title": "Attachment Actions",
+    "attachment-move-storage-fs": "Move attachment to filesystem",
+    "attachment-move-storage-gridfs": "Move attachment to GridFS",
+    "attachment-move": "Move Attachment",
+    "move-all-attachments-to-fs": "Move all attachments to filesystem",
+    "move-all-attachments-to-gridfs": "Move all attachments to GridFS",
+    "move-all-attachments-of-board-to-fs": "Move all attachments of board to filesystem",
+    "move-all-attachments-of-board-to-gridfs": "Move all attachments of board to GridFS",
+    "path": "Path",
+    "version-name": "Version-Name",
+    "size": "Size",
+    "storage": "Storage",
+    "action": "Action",
+    "board-title": "Board Title"
 }

+ 2 - 3
models/activities.js

@@ -247,9 +247,8 @@ if (Meteor.isServer) {
       params.commentId = comment._id;
     }
     if (activity.attachmentId) {
-      const attachment = activity.attachment();
-      params.attachment = attachment.name;
-      params.attachmentId = attachment._id;
+      params.attachment = activity.attachmentName;
+      params.attachmentId = activity.attachmentId;
     }
     if (activity.checklistId) {
       const checklist = activity.checklist();

+ 39 - 34
models/attachments.js

@@ -1,30 +1,17 @@
 import { Meteor } from 'meteor/meteor';
 import { FilesCollection } from 'meteor/ostrio:files';
 import path from 'path';
-import { createBucket } from './lib/grid/createBucket';
-import { createOnAfterUpload } from './lib/fsHooks/createOnAfterUpload';
-import { createInterceptDownload } from './lib/fsHooks/createInterceptDownload';
-import { createOnAfterRemove } from './lib/fsHooks/createOnAfterRemove';
+import { AttachmentStoreStrategyFilesystem, AttachmentStoreStrategyGridFs} from '/models/lib/attachmentStoreStrategy';
+import FileStoreStrategyFactory, {moveToStorage, STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS} from '/models/lib/fileStoreStrategy';
 
 let attachmentBucket;
+let storagePath;
 if (Meteor.isServer) {
   attachmentBucket = createBucket('attachments');
+  storagePath = path.join(process.env.WRITABLE_PATH, 'attachments');
 }
 
-const insertActivity = (fileObj, activityType) =>
-  Activities.insert({
-    userId: fileObj.userId,
-    type: 'card',
-    activityType,
-    attachmentId: fileObj._id,
-    // this preserves the name so that notifications can be meaningful after
-    // this file is removed
-    attachmentName: fileObj.name,
-    boardId: fileObj.meta.boardId,
-    cardId: fileObj.meta.cardId,
-    listId: fileObj.meta.listId,
-    swimlaneId: fileObj.meta.swimlaneId,
-  });
+export const fileStoreStrategyFactory = new FileStoreStrategyFactory(AttachmentStoreStrategyFilesystem, storagePath, AttachmentStoreStrategyGridFs, attachmentBucket);
 
 // XXX Enforce a schema for the Attachments FilesCollection
 // see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
@@ -33,26 +20,34 @@ Attachments = new FilesCollection({
   debug: false, // Change to `true` for debugging
   collectionName: 'attachments',
   allowClientCode: true,
+  namingFunction(opts) {
+    const filenameWithoutExtension = opts.name.replace(/(.+)\..+/, "$1");
+    const ret = opts.meta.fileId + "-original-" + filenameWithoutExtension;
+    // remove fileId from meta, it was only stored there to have this information here in the namingFunction function
+    delete opts.meta.fileId;
+    return ret;
+  },
   storagePath() {
-    if (process.env.WRITABLE_PATH) {
-      return path.join(process.env.WRITABLE_PATH, 'uploads', 'attachments');
-    }
-    return path.normalize(`assets/app/uploads/${this.collectionName}`);
+    const ret = fileStoreStrategyFactory.storagePath;
+    return ret;
   },
-  onAfterUpload: function onAfterUpload(fileRef) {
-    createOnAfterUpload(attachmentBucket).call(this, fileRef);
-    // If the attachment doesn't have a source field
-    // or its source is different than import
-    if (!fileRef.meta.source || fileRef.meta.source !== 'import') {
-      // Add activity about adding the attachment
-      insertActivity(fileRef, 'addAttachment');
-    }
+  onAfterUpload(fileObj) {
+    // current storage is the filesystem, update object and database
+    Object.keys(fileObj.versions).forEach(versionName => {
+      fileObj.versions[versionName].storage = STORAGE_NAME_FILESYSTEM;
+    });
+    Attachments.update({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
+    moveToStorage(fileObj, STORAGE_NAME_GRIDFS, fileStoreStrategyFactory);
   },
-  interceptDownload: createInterceptDownload(attachmentBucket),
-  onAfterRemove: function onAfterRemove(files) {
-    createOnAfterRemove(attachmentBucket).call(this, files);
+  interceptDownload(http, fileObj, versionName) {
+    const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
+    return ret;
+  },
+  onAfterRemove(files) {
     files.forEach(fileObj => {
-      insertActivity(fileObj, 'deleteAttachment');
+      Object.keys(fileObj.versions).forEach(versionName => {
+        fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
+      });
     });
   },
   // We authorize the attachment download either:
@@ -81,6 +76,16 @@ if (Meteor.isServer) {
     fetch: ['meta'],
   });
 
+  Meteor.methods({
+    moveAttachmentToStorage(fileObjId, storageDestination) {
+      check(fileObjId, String);
+      check(storageDestination, String);
+
+      const fileObj = Attachments.findOne({_id: fileObjId});
+      moveToStorage(fileObj, storageDestination, fileStoreStrategyFactory);
+    },
+  });
+
   Meteor.startup(() => {
     Attachments.collection.createIndex({ cardId: 1 });
   });

+ 25 - 11
models/avatars.js

@@ -1,25 +1,24 @@
 import { Meteor } from 'meteor/meteor';
 import { FilesCollection } from 'meteor/ostrio:files';
 import path from 'path';
-import { createBucket } from './lib/grid/createBucket';
-import { createOnAfterUpload } from './lib/fsHooks/createOnAfterUpload';
-import { createInterceptDownload } from './lib/fsHooks/createInterceptDownload';
-import { createOnAfterRemove } from './lib/fsHooks/createOnAfterRemove';
+import FileStoreStrategyFactory, { FileStoreStrategyFilesystem, FileStoreStrategyGridFs} from '/models/lib/fileStoreStrategy';
 
 let avatarsBucket;
+let storagePath;
 if (Meteor.isServer) {
   avatarsBucket = createBucket('avatars');
+  storagePath = path.join(process.env.WRITABLE_PATH, 'avatars');
 }
 
+const fileStoreStrategyFactory = new FileStoreStrategyFactory(FileStoreStrategyFilesystem, storagePath, FileStoreStrategyGridFs, avatarsBucket);
+
 Avatars = new FilesCollection({
   debug: false, // Change to `true` for debugging
   collectionName: 'avatars',
   allowClientCode: true,
   storagePath() {
-    if (process.env.WRITABLE_PATH) {
-      return path.join(process.env.WRITABLE_PATH, 'uploads', 'avatars');
-    }
-    return path.normalize(`assets/app/uploads/${this.collectionName}`);;
+    const ret = fileStoreStrategyFactory.storagePath;
+    return ret;
   },
   onBeforeUpload(file) {
     if (file.size <= 72000 && file.type.startsWith('image/')) {
@@ -27,9 +26,24 @@ Avatars = new FilesCollection({
     }
     return 'avatar-too-big';
   },
-  onAfterUpload: createOnAfterUpload(avatarsBucket),
-  interceptDownload: createInterceptDownload(avatarsBucket),
-  onAfterRemove: createOnAfterRemove(avatarsBucket),
+  onAfterUpload(fileObj) {
+    // current storage is the filesystem, update object and database
+    Object.keys(fileObj.versions).forEach(versionName => {
+      fileObj.versions[versionName].storage = "fs";
+    });
+    Avatars.update({ _id: fileObj._id }, { $set: { "versions" : fileObj.versions } });
+  },
+  interceptDownload(http, fileObj, versionName) {
+    const ret = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).interceptDownload(http, this.cacheControl);
+    return ret;
+  },
+  onAfterRemove(files) {
+    files.forEach(fileObj => {
+      Object.keys(fileObj.versions).forEach(versionName => {
+        fileStoreStrategyFactory.getFileStrategy(fileObj, versionName).onAfterRemove();
+      });
+    });
+  },
 });
 
 function isOwner(userId, doc) {

+ 72 - 0
models/lib/attachmentStoreStrategy.js

@@ -0,0 +1,72 @@
+import fs from 'fs';
+import FileStoreStrategy, {FileStoreStrategyFilesystem, FileStoreStrategyGridFs} from './fileStoreStrategy'
+
+const insertActivity = (fileObj, activityType) =>
+  Activities.insert({
+    userId: fileObj.userId,
+    type: 'card',
+    activityType,
+    attachmentId: fileObj._id,
+    attachmentName: fileObj.name,
+    boardId: fileObj.meta.boardId,
+    cardId: fileObj.meta.cardId,
+    listId: fileObj.meta.listId,
+    swimlaneId: fileObj.meta.swimlaneId,
+  });
+
+/** Strategy to store attachments at GridFS (MongoDB) */
+export class AttachmentStoreStrategyGridFs extends FileStoreStrategyGridFs {
+
+  /** constructor
+   * @param gridFsBucket use this GridFS Bucket
+   * @param fileObj the current file object
+   * @param versionName the current version
+   */
+  constructor(gridFsBucket, fileObj, versionName) {
+    super(gridFsBucket, fileObj, versionName);
+  }
+
+  /** after successfull upload */
+  onAfterUpload() {
+    super.onAfterUpload();
+    // If the attachment doesn't have a source field or its source is different than import
+    if (!this.fileObj.meta.source || this.fileObj.meta.source !== 'import') {
+      // Add activity about adding the attachment
+      insertActivity(this.fileObj, 'addAttachment');
+    }
+  }
+
+  /** after file remove */
+  onAfterRemove() {
+    super.onAfterRemove();
+    insertActivity(this.fileObj, 'deleteAttachment');
+  }
+}
+
+/** Strategy to store attachments at filesystem */
+export class AttachmentStoreStrategyFilesystem extends FileStoreStrategyFilesystem {
+
+  /** constructor
+   * @param fileObj the current file object
+   * @param versionName the current version
+   */
+  constructor(fileObj, versionName) {
+    super(fileObj, versionName);
+  }
+
+  /** after successfull upload */
+  onAfterUpload() {
+    super.onAfterUpload();
+    // If the attachment doesn't have a source field or its source is different than import
+    if (!this.fileObj.meta.source || this.fileObj.meta.source !== 'import') {
+      // Add activity about adding the attachment
+      insertActivity(this.fileObj, 'addAttachment');
+    }
+  }
+
+  /** after file remove */
+  onAfterRemove() {
+    super.onAfterRemove();
+    insertActivity(this.fileObj, 'deleteAttachment');
+  }
+}

+ 338 - 0
models/lib/fileStoreStrategy.js

@@ -0,0 +1,338 @@
+import fs from 'fs';
+import path from 'path';
+import { createObjectId } from './grid/createObjectId';
+import { httpStreamOutput } from './httpStream.js'
+
+export const STORAGE_NAME_FILESYSTEM = "fs";
+export const STORAGE_NAME_GRIDFS     = "gridfs";
+
+/** Factory for FileStoreStrategy */
+export default class FileStoreStrategyFactory {
+
+  /** constructor
+   * @param classFileStoreStrategyFilesystem use this strategy for filesystem storage
+   * @param storagePath file storage path
+   * @param classFileStoreStrategyGridFs use this strategy for GridFS storage
+   * @param gridFsBucket use this GridFS Bucket as GridFS Storage
+   */
+  constructor(classFileStoreStrategyFilesystem, storagePath, classFileStoreStrategyGridFs, gridFsBucket) {
+    this.classFileStoreStrategyFilesystem = classFileStoreStrategyFilesystem;
+    this.storagePath = storagePath;
+    this.classFileStoreStrategyGridFs = classFileStoreStrategyGridFs;
+    this.gridFsBucket = gridFsBucket;
+  }
+
+  /** returns the right FileStoreStrategy
+   * @param fileObj the current file object
+   * @param versionName the current version
+   * @param use this storage, or if not set, get the storage from fileObj
+   */
+  getFileStrategy(fileObj, versionName, storage) {
+    if (!storage) {
+      storage = fileObj.versions[versionName].storage;
+      if (!storage) {
+        if (fileObj.meta.source == "import") {
+          // uploaded by import, so it's in GridFS (MongoDB)
+          storage = STORAGE_NAME_GRIDFS;
+        } else {
+          // newly uploaded, so it's at the filesystem
+          storage = STORAGE_NAME_FILESYSTEM;
+        }
+      }
+    }
+    let ret;
+    if ([STORAGE_NAME_FILESYSTEM, STORAGE_NAME_GRIDFS].includes(storage)) {
+      if (storage == STORAGE_NAME_FILESYSTEM) {
+        ret = new this.classFileStoreStrategyFilesystem(fileObj, versionName);
+      } else if (storage == STORAGE_NAME_GRIDFS) {
+        ret = new this.classFileStoreStrategyGridFs(this.gridFsBucket, fileObj, versionName);
+      }
+    }
+    return ret;
+  }
+}
+
+/** Strategy to store files */
+class FileStoreStrategy {
+
+  /** constructor
+   * @param fileObj the current file object
+   * @param versionName the current version
+   */
+  constructor(fileObj, versionName) {
+    this.fileObj = fileObj;
+    this.versionName = versionName;
+  }
+
+  /** after successfull upload */
+  onAfterUpload() {
+  }
+
+  /** download the file
+   * @param http the current http request
+   * @param cacheControl cacheControl of FilesCollection
+   */
+  interceptDownload(http, cacheControl) {
+  }
+
+  /** after file remove */
+  onAfterRemove() {
+  }
+
+  /** returns a read stream
+   * @return the read stream
+   */
+  getReadStream() {
+  }
+
+  /** returns a write stream
+   * @param filePath if set, use this path
+   * @return the write stream
+   */
+  getWriteStream(filePath) {
+  }
+
+  /** writing finished
+   * @param finishedData the data of the write stream finish event
+   */
+  writeStreamFinished(finishedData) {
+  }
+
+  /** returns the new file path
+   * @param storagePath use this storage path
+   * @return the new file path
+   */
+  getNewPath(storagePath, name) {
+    if (!_.isString(name)) {
+      name = this.fileObj.name;
+    }
+    const ret = path.join(storagePath, this.fileObj._id + "-" + this.versionName + "-" + name);
+    return ret;
+  }
+
+  /** remove the file */
+  unlink() {
+  }
+
+  /** return the storage name
+   * @return the storage name
+   */
+  getStorageName() {
+  }
+}
+
+/** Strategy to store attachments at GridFS (MongoDB) */
+export class FileStoreStrategyGridFs extends FileStoreStrategy {
+
+  /** constructor
+   * @param gridFsBucket use this GridFS Bucket
+   * @param fileObj the current file object
+   * @param versionName the current version
+   */
+  constructor(gridFsBucket, fileObj, versionName) {
+    super(fileObj, versionName);
+    this.gridFsBucket = gridFsBucket;
+  }
+
+  /** download the file
+   * @param http the current http request
+   * @param cacheControl cacheControl of FilesCollection
+   */
+  interceptDownload(http, cacheControl) {
+    const readStream = this.getReadStream();
+    const downloadFlag = http?.params?.query?.download;
+
+    let ret = false;
+    if (readStream) {
+      ret = true;
+      httpStreamOutput(readStream, this.fileObj.name, http, downloadFlag, cacheControl);
+    }
+
+    return ret;
+  }
+
+  /** after file remove */
+  onAfterRemove() {
+    this.unlink();
+    super.onAfterRemove();
+  }
+
+  /** returns a read stream
+   * @return the read stream
+   */
+  getReadStream() {
+    const gfsId = this.getGridFsObjectId();
+    let ret;
+    if (gfsId) {
+      ret = this.gridFsBucket.openDownloadStream(gfsId);
+    }
+    return ret;
+  }
+
+  /** returns a write stream
+   * @param filePath if set, use this path
+   * @return the write stream
+   */
+  getWriteStream(filePath) {
+    const fileObj = this.fileObj;
+    const versionName = this.versionName;
+    const metadata = { ...fileObj.meta, versionName, fileId: fileObj._id };
+    const ret = this.gridFsBucket.openUploadStream(this.fileObj.name, {
+      contentType: fileObj.type || 'binary/octet-stream',
+      metadata,
+    });
+    return ret;
+  }
+
+  /** writing finished
+   * @param finishedData the data of the write stream finish event
+   */
+  writeStreamFinished(finishedData) {
+    const gridFsFileIdName = this.getGridFsFileIdName();
+    Attachments.update({ _id: this.fileObj._id }, { $set: { [gridFsFileIdName]: finishedData._id.toHexString(), } });
+  }
+
+  /** remove the file */
+  unlink() {
+    const gfsId = this.getGridFsObjectId();
+    if (gfsId) {
+      this.gridFsBucket.delete(gfsId, err => {
+        if (err) {
+          console.error("error on gfs bucket.delete: ", err);
+        }
+      });
+    }
+
+    const gridFsFileIdName = this.getGridFsFileIdName();
+    Attachments.update({ _id: this.fileObj._id }, { $unset: { [gridFsFileIdName]: 1 } });
+  }
+
+  /** return the storage name
+   * @return the storage name
+   */
+  getStorageName() {
+    return STORAGE_NAME_GRIDFS;
+  }
+
+  /** returns the GridFS Object-Id
+   * @return the GridFS Object-Id
+   */
+  getGridFsObjectId() {
+    let ret;
+    const gridFsFileId = this.getGridFsFileId();
+    if (gridFsFileId) {
+      ret = createObjectId({ gridFsFileId });
+    }
+    return ret;
+  }
+
+  /** returns the GridFS Object-Id
+   * @return the GridFS Object-Id
+   */
+  getGridFsFileId() {
+    const ret = (this.fileObj.versions[this.versionName].meta || {})
+      .gridFsFileId;
+    return ret;
+  }
+
+  /** returns the property name of gridFsFileId
+   * @return the property name of gridFsFileId
+   */
+  getGridFsFileIdName() {
+    const ret = `versions.${this.versionName}.meta.gridFsFileId`;
+    return ret;
+  }
+}
+
+/** Strategy to store attachments at filesystem */
+export class FileStoreStrategyFilesystem extends FileStoreStrategy {
+
+  /** constructor
+   * @param fileObj the current file object
+   * @param versionName the current version
+   */
+  constructor(fileObj, versionName) {
+    super(fileObj, versionName);
+  }
+
+  /** returns a read stream
+   * @return the read stream
+   */
+  getReadStream() {
+    const ret = fs.createReadStream(this.fileObj.versions[this.versionName].path)
+    return ret;
+  }
+
+  /** returns a write stream
+   * @param filePath if set, use this path
+   * @return the write stream
+   */
+  getWriteStream(filePath) {
+    if (!_.isString(filePath)) {
+      filePath = this.fileObj.versions[this.versionName].path;
+    }
+    const ret = fs.createWriteStream(filePath);
+    return ret;
+  }
+
+  /** writing finished
+   * @param finishedData the data of the write stream finish event
+   */
+  writeStreamFinished(finishedData) {
+  }
+
+  /** remove the file */
+  unlink() {
+    const filePath = this.fileObj.versions[this.versionName].path;
+    fs.unlink(filePath, () => {});
+  }
+
+  /** return the storage name
+   * @return the storage name
+   */
+  getStorageName() {
+    return STORAGE_NAME_FILESYSTEM;
+  }
+}
+
+/** move the fileObj to another storage
+ * @param fileObj move this fileObj to another storage
+ * @param storageDestination the storage destination (fs or gridfs)
+ * @param fileStoreStrategyFactory get FileStoreStrategy from this factory
+ */
+export const moveToStorage = function(fileObj, storageDestination, fileStoreStrategyFactory) {
+  Object.keys(fileObj.versions).forEach(versionName => {
+    const strategyRead = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName);
+    const strategyWrite = fileStoreStrategyFactory.getFileStrategy(fileObj, versionName, storageDestination);
+
+    if (strategyRead.constructor.name != strategyWrite.constructor.name) {
+      const readStream = strategyRead.getReadStream();
+
+      const filePath = strategyWrite.getNewPath(fileStoreStrategyFactory.storagePath);
+      const writeStream = strategyWrite.getWriteStream(filePath);
+
+      writeStream.on('error', error => {
+        console.error('[writeStream error]: ', error, fileObjId);
+      });
+
+      readStream.on('error', error => {
+        console.error('[readStream error]: ', error, fileObjId);
+      });
+
+      writeStream.on('finish', Meteor.bindEnvironment((finishedData) => {
+        strategyWrite.writeStreamFinished(finishedData);
+      }));
+
+      // https://forums.meteor.com/t/meteor-code-must-always-run-within-a-fiber-try-wrapping-callbacks-that-you-pass-to-non-meteor-libraries-with-meteor-bindenvironmen/40099/8
+      readStream.on('end', Meteor.bindEnvironment(() => {
+        Attachments.update({ _id: fileObj._id }, { $set: {
+          [`versions.${versionName}.storage`]: strategyWrite.getStorageName(),
+          [`versions.${versionName}.path`]: filePath,
+        } });
+        strategyRead.unlink();
+      }));
+
+      readStream.pipe(writeStream);
+    }
+  });
+};

+ 0 - 47
models/lib/fsHooks/createInterceptDownload.js

@@ -1,47 +0,0 @@
-import { createObjectId } from '../grid/createObjectId';
-
-export const createInterceptDownload = bucket =>
-  function interceptDownload(http, file, versionName) {
-    const { gridFsFileId } = file.versions[versionName].meta || {};
-    if (gridFsFileId) {
-      // opens the download stream using a given gfs id
-      // see: http://mongodb.github.io/node-mongodb-native/3.2/api/GridFSBucket.html#openDownloadStream
-      const gfsId = createObjectId({ gridFsFileId });
-      const readStream = bucket.openDownloadStream(gfsId);
-
-      readStream.on('data', data => {
-        http.response.write(data);
-      });
-
-      readStream.on('end', () => {
-        http.response.end(); // don't pass parameters to end() or it will be attached to the file's binary stream
-      });
-
-      readStream.on('error', () => {
-        // not found probably
-        // eslint-disable-next-line no-param-reassign
-        http.response.statusCode = 404;
-        http.response.end('not found');
-      });
-
-      http.response.setHeader('Cache-Control', this.cacheControl);
-      http.response.setHeader(
-        'Content-Disposition',
-        getContentDisposition(file.name, http?.params?.query?.download),
-      );
-    }
-    return Boolean(gridFsFileId); // Serve file from either GridFS or FS if it wasn't uploaded yet
-  };
-
-/**
- * Will initiate download, if links are called with ?download="true" queryparam.
- **/
-const getContentDisposition = (name, downloadFlag) => {
-  const dispositionType = downloadFlag === 'true' ? 'attachment;' : 'inline;';
-
-  const encodedName = encodeURIComponent(name);
-  const dispositionName = `filename="${encodedName}"; filename=*UTF-8"${encodedName}";`;
-  const dispositionEncoding = 'charset=utf-8';
-
-  return `${dispositionType} ${dispositionName} ${dispositionEncoding}`;
-};

+ 0 - 17
models/lib/fsHooks/createOnAfterRemove.js

@@ -1,17 +0,0 @@
-import { createObjectId } from '../grid/createObjectId';
-
-export const createOnAfterRemove = bucket =>
-  function onAfterRemove(files) {
-    files.forEach(file => {
-      Object.keys(file.versions).forEach(versionName => {
-        const gridFsFileId = (file.versions[versionName].meta || {})
-          .gridFsFileId;
-        if (gridFsFileId) {
-          const gfsId = createObjectId({ gridFsFileId });
-          bucket.delete(gfsId, err => {
-            // if (err) console.error(err);
-          });
-        }
-      });
-    });
-  };

+ 31 - 0
models/lib/httpStream.js

@@ -0,0 +1,31 @@
+export const httpStreamOutput = function(readStream, name, http, downloadFlag, cacheControl) {
+    readStream.on('data', data => {
+      http.response.write(data);
+    });
+
+    readStream.on('end', () => {
+      // don't pass parameters to end() or it will be attached to the file's binary stream
+      http.response.end();
+    });
+
+    readStream.on('error', () => {
+      http.response.statusCode = 404;
+      http.response.end('not found');
+    });
+
+    if (cacheControl) {
+      http.response.setHeader('Cache-Control', cacheControl);
+    }
+    http.response.setHeader('Content-Disposition', getContentDisposition(name, http?.params?.query?.download));
+  };
+
+/** will initiate download, if links are called with ?download="true" queryparam */
+const getContentDisposition = (name, downloadFlag) => {
+  const dispositionType = downloadFlag === 'true' ? 'attachment;' : 'inline;';
+
+  const encodedName = encodeURIComponent(name);
+  const dispositionName = `filename="${encodedName}"; filename=*UTF-8"${encodedName}";`;
+  const dispositionEncoding = 'charset=utf-8';
+
+  return `${dispositionType} ${dispositionName} ${dispositionEncoding}`;
+};

+ 10 - 1
server/migrations.js

@@ -1216,7 +1216,7 @@ Migrations.add('migrate-attachments-collectionFS-to-ostrioFiles', () => {
             cardId: fileObj.cardId,
             listId: fileObj.listId,
             swimlaneId: fileObj.swimlaneId,
-            source: 'import,'
+            source: 'import'
           },
           userId,
           size: fileSize,
@@ -1328,3 +1328,12 @@ Migrations.add('migrate-attachment-drop-index-cardId', () => {
   } catch (error) {
   }
 });
+
+Migrations.add('migrate-attachment-migration-fix-source-import', () => {
+  // there was an error at first versions, so source was import, instead of import
+  Attachments.update(
+    {"meta.source":"import,"},
+    {$set:{"meta.source":"import"}},
+    noValidateMulti
+  );
+});

+ 13 - 54
server/publications/attachments.js

@@ -1,65 +1,24 @@
-import Attachments, { AttachmentStorage } from '/models/attachments';
+import Attachments from '/models/attachments';
 import { ObjectID } from 'bson';
 
-Meteor.publish('attachmentsList', function() {
-  // eslint-disable-next-line no-console
-  // console.log('attachments:', AttachmentStorage.find());
-  const files = AttachmentStorage.find(
+Meteor.publish('attachmentsList', function(limit) {
+  const ret = Attachments.find(
     {},
     {
       fields: {
         _id: 1,
-        filename: 1,
-        md5: 1,
-        length: 1,
-        contentType: 1,
-        metadata: 1,
+        name: 1,
+        size: 1,
+        type: 1,
+        meta: 1,
+        path: 1,
+        versions: 1,
       },
       sort: {
-        filename: 1,
+        name: 1,
       },
-      limit: 250,
+      limit: limit,
     },
-  );
-  const attIds = [];
-  files.forEach(file => {
-    attIds.push(file._id._str);
-  });
-
-  return [
-    files,
-    Attachments.find({ 'copies.attachments.key': { $in: attIds } }),
-  ];
-});
-
-Meteor.publish('orphanedAttachments', function() {
-  let keys = [];
-
-  if (Attachments.find({}, { fields: { copies: 1 } }) !== undefined) {
-    Attachments.find({}, { fields: { copies: 1 } }).forEach(att => {
-      keys.push(new ObjectID(att.copies.attachments.key));
-    });
-    keys.sort();
-    keys = _.uniq(keys, true);
-
-    return AttachmentStorage.find(
-      { _id: { $nin: keys } },
-      {
-        fields: {
-          _id: 1,
-          filename: 1,
-          md5: 1,
-          length: 1,
-          contentType: 1,
-          metadata: 1,
-        },
-        sort: {
-          filename: 1,
-        },
-        limit: 250,
-      },
-    );
-  } else {
-    return [];
-  }
+  ).cursor;
+  return ret;
 });