Merge branch 'lib-change' of https://github.com/PDIS/wekan into

PDIS-lib-change
This commit is contained in:
Lauri Ojansivu 2020-05-24 03:13:53 +03:00
commit 055b528532
24 changed files with 823 additions and 442 deletions

View file

@ -98,3 +98,4 @@ percolate:synced-cron
easylogic:summernote easylogic:summernote
cfs:filesystem cfs:filesystem
ostrio:cookies ostrio:cookies
ostrio:files

View file

@ -134,6 +134,7 @@ observe-sequence@1.0.16
ongoworks:speakingurl@1.1.0 ongoworks:speakingurl@1.1.0
ordered-dict@1.1.0 ordered-dict@1.1.0
ostrio:cookies@2.6.0 ostrio:cookies@2.6.0
ostrio:files@1.14.2
peerlibrary:assert@0.3.0 peerlibrary:assert@0.3.0
peerlibrary:base-component@0.16.0 peerlibrary:base-component@0.16.0
peerlibrary:blaze-components@0.15.1 peerlibrary:blaze-components@0.15.1

View file

@ -152,17 +152,18 @@ BlazeComponent.extendComponent({
attachmentLink() { attachmentLink() {
const attachment = this.currentData().activity.attachment(); const attachment = this.currentData().activity.attachment();
const link = attachment.link('original', '/');
// trying to display url before file is stored generates js errors // trying to display url before file is stored generates js errors
return ( return (
(attachment && (attachment &&
attachment.url({ download: true }) && link &&
Blaze.toHTML( Blaze.toHTML(
HTML.A( HTML.A(
{ {
href: attachment.url({ download: true }), href: link,
target: '_blank', target: '_blank',
}, },
attachment.name(), attachment.name,
), ),
)) || )) ||
this.currentData().activity.attachmentName this.currentData().activity.attachmentName

View file

@ -18,12 +18,19 @@ template(name="attachmentDeletePopup")
p {{_ "attachment-delete-pop"}} p {{_ "attachment-delete-pop"}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}} button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
template(name="uploadingPopup")
.uploading-info
span.upload-percentage {{progress}}%
.upload-progress-frame
.upload-progress-bar(style="width: {{progress}}%;")
span.upload-size {{fileSize}}
template(name="attachmentsGalery") template(name="attachmentsGalery")
.attachments-galery .attachments-galery
each attachments each attachments
.attachment-item .attachment-item
a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}") a.attachment-thumbnail.swipebox(href="{{url}}" download="{{name}}" title="{{name}}")
if isUploaded if isUploaded
if isImage if isImage
img.attachment-thumbnail-img(src="{{url}}") img.attachment-thumbnail-img(src="{{url}}")
else else
@ -33,7 +40,7 @@ template(name="attachmentsGalery")
p.attachment-details p.attachment-details
= name = name
span.attachment-details-actions span.attachment-details-actions
a.js-download(href="{{url download=true}}") a.js-download(href="{{url download=true}}" download="{{name}}")
i.fa.fa-download i.fa.fa-download
| {{_ 'download'}} | {{_ 'download'}}
if currentUser.isBoardMember if currentUser.isBoardMember

View file

@ -13,10 +13,10 @@ Template.attachmentsGalery.events({
event.stopPropagation(); event.stopPropagation();
}, },
'click .js-add-cover'() { 'click .js-add-cover'() {
Cards.findOne(this.cardId).setCover(this._id); Cards.findOne(this.meta.cardId).setCover(this._id);
}, },
'click .js-remove-cover'() { 'click .js-remove-cover'() {
Cards.findOne(this.cardId).unsetCover(); Cards.findOne(this.meta.cardId).unsetCover();
}, },
'click .js-preview-image'(event) { 'click .js-preview-image'(event) {
Popup.open('previewAttachedImage').call(this, event); Popup.open('previewAttachedImage').call(this, event);
@ -45,22 +45,63 @@ Template.attachmentsGalery.events({
}, },
}); });
Template.attachmentsGalery.helpers({
url() {
return Attachments.link(this, 'original', '/');
},
isUploaded() {
return !this.meta.uploading;
},
isImage() {
return !!this.isImage;
},
});
Template.previewAttachedImagePopup.events({ Template.previewAttachedImagePopup.events({
'click .js-large-image-clicked'() { 'click .js-large-image-clicked'() {
Popup.close(); Popup.close();
}, },
}); });
Template.previewAttachedImagePopup.helpers({
url() {
return Attachments.link(this, 'original', '/');
}
});
// For uploading popup
let uploadFileSize = new ReactiveVar('');
let uploadProgress = new ReactiveVar(0);
Template.cardAttachmentsPopup.events({ Template.cardAttachmentsPopup.events({
'change .js-attach-file'(event) { 'change .js-attach-file'(event, instance) {
const card = this; const card = this;
const processFile = f => { const callbacks = {
Utils.processUploadedAttachment(card, f, attachment => { onBeforeUpload: (err, fileData) => {
if (attachment && attachment._id && attachment.isImage()) { Popup.open('uploading')(this.clickEvent);
card.setCover(attachment._id); uploadFileSize.set('...');
uploadProgress.set(0);
return true;
},
onUploaded: (err, attachment) => {
if (attachment && attachment._id && attachment.isImage) {
card.setCover(attachment._id);
}
Popup.close();
},
onStart: (error, fileData) => {
uploadFileSize.set(formatBytes(fileData.size));
},
onError: (err, fileObj) => {
console.log('Error!', err);
},
onProgress: (progress, fileData) => {
uploadProgress.set(progress);
} }
Popup.close(); };
}); const processFile = f => {
Utils.processUploadedAttachment(card, f, callbacks);
}; };
FS.Utility.eachFile(event, f => { FS.Utility.eachFile(event, f => {
@ -100,12 +141,22 @@ Template.cardAttachmentsPopup.events({
}); });
}, },
'click .js-computer-upload'(event, templateInstance) { 'click .js-computer-upload'(event, templateInstance) {
this.clickEvent = event;
templateInstance.find('.js-attach-file').click(); templateInstance.find('.js-attach-file').click();
event.preventDefault(); event.preventDefault();
}, },
'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'), 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
}); });
Template.uploadingPopup.helpers({
fileSize: () => {
return uploadFileSize.get();
},
progress: () => {
return uploadProgress.get();
}
});
const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
let pastedResults = null; let pastedResults = null;
@ -149,20 +200,26 @@ Template.previewClipboardImagePopup.events({
if (results && results.file) { if (results && results.file) {
window.oPasted = pastedResults; window.oPasted = pastedResults;
const card = this; const card = this;
const file = new FS.File(results.file); const settings = {
file: results.file,
streams: 'dynamic',
chunkSize: 'dynamic',
};
if (!results.name) { if (!results.name) {
// if no filename, it's from clipboard. then we give it a name, with ext name from MIME type // if no filename, it's from clipboard. then we give it a name, with ext name from MIME type
if (typeof results.file.type === 'string') { if (typeof results.file.type === 'string') {
file.name(results.file.type.replace('image/', 'clipboard.')); settings.fileName =
new Date().getTime() + results.file.type.replace('.+/', '');
} }
} }
file.updatedAt(new Date()); settings.meta = {};
file.boardId = card.boardId; settings.meta.updatedAt = new Date().getTime();
file.cardId = card._id; settings.meta.boardId = card.boardId;
file.userId = Meteor.userId(); settings.meta.cardId = card._id;
const attachment = Attachments.insert(file); settings.meta.userId = Meteor.userId();
const attachment = Attachments.insert(settings);
if (attachment && attachment._id && attachment.isImage()) { if (attachment && attachment._id && attachment.isImage) {
card.setCover(attachment._id); card.setCover(attachment._id);
} }
@ -172,3 +229,15 @@ Template.previewClipboardImagePopup.events({
} }
}, },
}); });
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}

View file

@ -64,6 +64,17 @@
border: 1px solid black border: 1px solid black
box-shadow: 0 1px 2px rgba(0,0,0,.2) box-shadow: 0 1px 2px rgba(0,0,0,.2)
.uploading-info
.upload-progress-frame
background-color: grey;
border: 1px solid;
height: 22px;
.upload-progress-bar
background-color: blue;
height: 20px;
padding: 1px;
@media screen and (max-width: 800px) @media screen and (max-width: 800px)
.attachments-galery .attachments-galery
flex-direction flex-direction

View file

@ -11,7 +11,7 @@ template(name="minicard")
.handle .handle
.fa.fa-arrows .fa.fa-arrows
if cover if cover
.minicard-cover(style="background-image: url('{{cover.url}}');") .minicard-cover(style="background-image: url('{{coverUrl}}');")
if labels if labels
.minicard-labels .minicard-labels
each labels each labels

View file

@ -52,4 +52,7 @@ Template.minicard.helpers({
return false; return false;
} }
}, },
coverUrl() {
return Attachments.findOne(this.coverId).link('original', '/');
},
}); });

View file

@ -152,33 +152,31 @@ Template.editor.onRendered(() => {
const processData = function(fileObj) { const processData = function(fileObj) {
Utils.processUploadedAttachment( Utils.processUploadedAttachment(
currentCard, currentCard,
fileObj, fileObj,
attachment => { { onUploaded:
if ( attachment => {
attachment && if (attachment && attachment._id && attachment.isImage) {
attachment._id && attachment.one('uploaded', function() {
attachment.isImage() const maxTry = 3;
) { const checkItvl = 500;
attachment.one('uploaded', function() { let retry = 0;
const maxTry = 3; const checkUrl = function() {
const checkItvl = 500; // even though uploaded event fired, attachment.url() is still null somehow //TODO
let retry = 0; const url = Attachments.link(attachment, 'original', '/');
const checkUrl = function() { if (url) {
// even though uploaded event fired, attachment.url() is still null somehow //TODO insertImage(
const url = attachment.url(); `${location.protocol}//${location.host}${url}`,
if (url) { );
insertImage( } else {
`${location.protocol}//${location.host}${url}`, retry++;
); if (retry < maxTry) {
} else { setTimeout(checkUrl, checkItvl);
retry++; }
if (retry < maxTry) {
setTimeout(checkUrl, checkItvl);
} }
} };
}; checkUrl();
checkUrl(); });
}); }
} }
}, },
); );

View file

@ -49,7 +49,7 @@ window.Popup = new (class {
// has one. This allows us to position a sub-popup exactly at the same // has one. This allows us to position a sub-popup exactly at the same
// position than its parent. // position than its parent.
let openerElement; let openerElement;
if (clickFromPopup(evt)) { if (clickFromPopup(evt) && self._getTopStack()) {
openerElement = self._getTopStack().openerElement; openerElement = self._getTopStack().openerElement;
} else { } else {
self._stack = []; self._stack = [];

View file

@ -61,30 +61,38 @@ Utils = {
}, },
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL, MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO, COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
processUploadedAttachment(card, fileObj, callback) { processUploadedAttachment(card, fileObj, callbacks) {
const next = attachment => {
if (typeof callback === 'function') {
callback(attachment);
}
};
if (!card) { if (!card) {
return next(); return onUploaded();
} }
const file = new FS.File(fileObj); let settings = {
file: fileObj,
streams: 'dynamic',
chunkSize: 'dynamic',
};
settings.meta = {
uploading: true
};
if (card.isLinkedCard()) { if (card.isLinkedCard()) {
file.boardId = Cards.findOne(card.linkedId).boardId; settings.meta.boardId = Cards.findOne(card.linkedId).boardId;
file.cardId = card.linkedId; settings.meta.cardId = card.linkedId;
} else { } else {
file.boardId = card.boardId; settings.meta.boardId = card.boardId;
file.swimlaneId = card.swimlaneId; settings.meta.swimlaneId = card.swimlaneId;
file.listId = card.listId; settings.meta.listId = card.listId;
file.cardId = card._id; settings.meta.cardId = card._id;
} }
file.userId = Meteor.userId(); settings.meta.userId = Meteor.userId();
if (file.original) { if (typeof callbacks === 'function') {
file.original.name = fileObj.name; settings.onEnd = callbacks;
} else {
for (const key in callbacks) {
if (key.substring(0, 2) === 'on') {
settings[key] = callbacks[key];
}
}
} }
return next(Attachments.insert(file)); Attachments.insert(settings);
}, },
shrinkImage(options) { shrinkImage(options) {
// shrink image to certain size // shrink image to certain size

View file

@ -747,7 +747,7 @@
"error-ldap-login": "嘗試登入時出現錯誤", "error-ldap-login": "嘗試登入時出現錯誤",
"display-authentication-method": "顯示認證方式", "display-authentication-method": "顯示認證方式",
"default-authentication-method": "預設認證方式", "default-authentication-method": "預設認證方式",
"duplicate-board": "重複的看板", "duplicate-board": "複製看板",
"people-number": "人數是:", "people-number": "人數是:",
"swimlaneDeletePopup-title": "是否刪除泳道?", "swimlaneDeletePopup-title": "是否刪除泳道?",
"swimlane-delete-pop": "所有動作將從活動來源中刪除,您將無法恢復泳道。此操作無法還原。", "swimlane-delete-pop": "所有動作將從活動來源中刪除,您將無法恢復泳道。此操作無法還原。",

View file

@ -217,7 +217,7 @@ if (Meteor.isServer) {
} }
if (activity.attachmentId) { if (activity.attachmentId) {
const attachment = activity.attachment(); const attachment = activity.attachment();
params.attachment = attachment.original.name; params.attachment = attachment.name;
params.attachmentId = attachment._id; params.attachmentId = attachment._id;
} }
if (activity.checklistId) { if (activity.checklistId) {

View file

@ -1,263 +1,127 @@
const localFSStore = process.env.ATTACHMENTS_STORE_PATH; import { FilesCollection } from 'meteor/ostrio:files';
const storeName = 'attachments'; const fs = require('fs');
const defaultStoreOptions = {
beforeWrite: fileObj => {
if (!fileObj.isImage()) {
return {
type: 'application/octet-stream',
};
}
return {};
},
};
let store;
if (localFSStore) {
// have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem
const fs = Npm.require('fs');
const path = Npm.require('path');
const mongodb = Npm.require('mongodb');
const Grid = Npm.require('gridfs-stream');
// calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :(
let pathname = localFSStore;
/*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */
if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) { const collectionName = 'attachments2';
pathname = path.join(
__meteor_bootstrap__.serverDir,
`../../../cfs/files/${storeName}`,
);
}
if (!pathname) Attachments = new FilesCollection({
throw new Error('FS.Store.FileSystem unable to determine path'); storagePath: storagePath(),
debug: false,
// Check if we have '~/foo/bar' // allowClientCode: true,
if (pathname.split(path.sep)[0] === '~') { collectionName: 'attachments2',
const homepath = onAfterUpload: onAttachmentUploaded,
process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; onBeforeRemove: onAttachmentRemoving
if (homepath) {
pathname = pathname.replace('~', homepath);
} else {
throw new Error('FS.Store.FileSystem unable to resolve "~" in path');
}
}
// Set absolute path
const absolutePath = path.resolve(pathname);
const _FStore = new FS.Store.FileSystem(storeName, {
path: localFSStore,
...defaultStoreOptions,
});
const GStore = {
fileKey(fileObj) {
const key = {
_id: null,
filename: null,
};
// If we're passed a fileObj, we retrieve the _id and filename from it.
if (fileObj) {
const info = fileObj._getInfo(storeName, {
updateFileRecordFirst: false,
});
key._id = info.key || null;
key.filename =
info.name ||
fileObj.name({ updateFileRecordFirst: false }) ||
`${fileObj.collectionName}-${fileObj._id}`;
}
// If key._id is null at this point, createWriteStream will let GridFS generate a new ID
return key;
},
db: undefined,
mongoOptions: { useNewUrlParser: true },
mongoUrl: process.env.MONGO_URL,
init() {
this._init(err => {
this.inited = !err;
});
},
_init(callback) {
const self = this;
mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function(
err,
db,
) {
if (err) {
return callback(err);
}
self.db = db;
return callback(null);
});
return;
},
createReadStream(fileKey, options) {
const self = this;
if (!self.inited) {
self.init();
return undefined;
}
options = options || {};
// Init GridFS
const gfs = new Grid(self.db, mongodb);
// Set the default streamning settings
const settings = {
_id: new mongodb.ObjectID(fileKey._id),
root: `cfs_gridfs.${storeName}`,
};
// Check if this should be a partial read
if (
typeof options.start !== 'undefined' &&
typeof options.end !== 'undefined'
) {
// Add partial info
settings.range = {
startPos: options.start,
endPos: options.end,
};
}
return gfs.createReadStream(settings);
},
};
GStore.init();
const CRS = 'createReadStream';
const _CRS = `_${CRS}`;
const FStore = _FStore._transform;
FStore[_CRS] = FStore[CRS].bind(FStore);
FStore[CRS] = function(fileObj, options) {
let stream;
try {
const localFile = path.join(
absolutePath,
FStore.storage.fileKey(fileObj),
);
const state = fs.statSync(localFile);
if (state) {
stream = FStore[_CRS](fileObj, options);
}
} catch (e) {
// file is not there, try GridFS ?
stream = undefined;
}
if (stream) return stream;
else {
try {
const stream = GStore[CRS](GStore.fileKey(fileObj), options);
return stream;
} catch (e) {
return undefined;
}
}
}.bind(FStore);
store = _FStore;
} else {
store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, {
// XXX Add a new store for cover thumbnails so we don't load big images in
// the general board view
// If the uploaded document is not an image we need to enforce browser
// download instead of execution. This is particularly important for HTML
// files that the browser will just execute if we don't serve them with the
// appropriate `application/octet-stream` MIME header which can lead to user
// data leaks. I imagine other formats (like PDF) can also be attack vectors.
// See https://github.com/wekan/wekan/issues/99
// XXX Should we use `beforeWrite` option of CollectionFS instead of
// collection-hooks?
// We should use `beforeWrite`.
...defaultStoreOptions,
});
}
Attachments = new FS.Collection('attachments', {
stores: [store],
}); });
if (Meteor.isServer) { if (Meteor.isServer) {
Meteor.startup(() => { Meteor.startup(() => {
Attachments.files._ensureIndex({ cardId: 1 }); Attachments.collection._ensureIndex({ cardId: 1 });
}); });
// TODO: Permission related
Attachments.allow({ Attachments.allow({
insert(userId, doc) { insert() {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); return false;
}, },
update(userId, doc) { update() {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); return true;
}, },
remove(userId, doc) { remove() {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); return true;
},
// We authorize the attachment download either:
// - if the board is public, everyone (even unconnected) can download it
// - if the board is private, only board members can download it
download(userId, doc) {
const board = Boards.findOne(doc.boardId);
if (board.isPublic()) {
return true;
} else {
return board.hasMember(userId);
}
},
fetch: ['boardId'],
});
}
// XXX Enforce a schema for the Attachments CollectionFS
if (Meteor.isServer) {
Attachments.files.after.insert((userId, doc) => {
// If the attachment doesn't have a source field
// or its source is different than import
if (!doc.source || doc.source !== 'import') {
// Add activity about adding the attachment
Activities.insert({
userId,
type: 'card',
activityType: 'addAttachment',
attachmentId: doc._id,
// this preserves the name so that notifications can be meaningful after
// this file is removed
attachmentName: doc.original.name,
boardId: doc.boardId,
cardId: doc.cardId,
listId: doc.listId,
swimlaneId: doc.swimlaneId,
});
} else {
// Don't add activity about adding the attachment as the activity
// be imported and delete source field
Attachments.update(
{
_id: doc._id,
},
{
$unset: {
source: '',
},
},
);
} }
}); });
Attachments.files.before.remove((userId, doc) => { Meteor.methods({
Activities.insert({ cloneAttachment(file, overrides) {
userId, check(file, Object);
type: 'card', check(overrides, Match.Maybe(Object));
activityType: 'deleteAttachment', const path = file.path;
attachmentId: doc._id, const opts = {
// this preserves the name so that notifications can be meaningful after fileName: file.name,
// this file is removed type: file.type,
attachmentName: doc.original.name, meta: file.meta,
boardId: doc.boardId, userId: file.userId
cardId: doc.cardId, };
listId: doc.listId, for (let key in overrides) {
swimlaneId: doc.swimlaneId, if (key === 'meta') {
}); for (let metaKey in overrides.meta) {
opts.meta[metaKey] = overrides.meta[metaKey];
}
} else {
opts[key] = overrides[key];
}
}
const buffer = fs.readFileSync(path);
Attachments.write(buffer, opts, (err, fileRef) => {
if (err) {
console.log('Error when cloning record', err);
}
});
return true;
}
}); });
Meteor.publish(collectionName, function() {
return Attachments.find().cursor;
});
} else {
Meteor.subscribe(collectionName);
}
function storagePath(defaultPath) {
const storePath = process.env.ATTACHMENTS_STORE_PATH;
return storePath ? storePath : defaultPath;
}
function onAttachmentUploaded(fileRef) {
Attachments.update({_id:fileRef._id}, {$set: {"meta.uploading": false}});
if (!fileRef.meta.source || fileRef.meta.source !== 'import') {
// Add activity about adding the attachment
Activities.insert({
userId: fileRef.userId,
type: 'card',
activityType: 'addAttachment',
attachmentId: fileRef._id,
// this preserves the name so that notifications can be meaningful after
// this file is removed
attachmentName: fileRef.name,
boardId: fileRef.meta.boardId,
cardId: fileRef.meta.cardId,
listId: fileRef.meta.listId,
swimlaneId: fileRef.meta.swimlaneId,
});
} else {
// Don't add activity about adding the attachment as the activity
// be imported and delete source field
Attachments.collection.update(
{
_id: fileRef._id,
},
{
$unset: {
'meta.source': '',
},
},
);
}
}
function onAttachmentRemoving(cursor) {
const file = cursor.get()[0];
const meta = file.meta;
Activities.insert({
userId: this.userId,
type: 'card',
activityType: 'deleteAttachment',
attachmentId: file._id,
// this preserves the name so that notifications can be meaningful after
// this file is removed
attachmentName: file.name,
boardId: meta.boardId,
cardId: meta.cardId,
listId: meta.listId,
swimlaneId: meta.swimlaneId,
});
return true;
} }
export default Attachments; export default Attachments;

View file

@ -412,10 +412,14 @@ Cards.helpers({
const _id = Cards.insert(this); const _id = Cards.insert(this);
// Copy attachments // Copy attachments
oldCard.attachments().forEach(att => { oldCard.attachments().forEach((file) => {
att.cardId = _id; Meteor.call('cloneAttachment', file,
delete att._id; {
return Attachments.insert(att); meta: {
cardId: _id
}
}
);
}); });
// copy checklists // copy checklists
@ -518,14 +522,15 @@ Cards.helpers({
attachments() { attachments() {
if (this.isLinkedCard()) { if (this.isLinkedCard()) {
return Attachments.find( return Attachments.find(
{ cardId: this.linkedId }, { 'meta.cardId': this.linkedId },
{ sort: { uploadedAt: -1 } }, { sort: { uploadedAt: -1 } },
); );
} else { } else {
return Attachments.find( let ret = Attachments.find(
{ cardId: this._id }, { 'meta.cardId': this._id },
{ sort: { uploadedAt: -1 } }, { sort: { uploadedAt: -1 } },
); );
return ret;
} }
}, },
@ -533,7 +538,7 @@ Cards.helpers({
const cover = Attachments.findOne(this.coverId); const cover = Attachments.findOne(this.coverId);
// if we return a cover before it is fully stored, we will get errors when we try to display it // if we return a cover before it is fully stored, we will get errors when we try to display it
// todo XXX we could return a default "upload pending" image in the meantime? // todo XXX we could return a default "upload pending" image in the meantime?
return cover && cover.url() && cover; return cover && cover.link();
}, },
checklists() { checklists() {

View file

@ -1,4 +1,3 @@
import { Exporter } from './exporter';
/* global JsonRoutes */ /* global JsonRoutes */
if (Meteor.isServer) { if (Meteor.isServer) {
// todo XXX once we have a real API in place, move that route there // todo XXX once we have a real API in place, move that route there
@ -8,10 +7,10 @@ if (Meteor.isServer) {
// on the client instead of copy/pasting the route path manually between the // on the client instead of copy/pasting the route path manually between the
// client and the server. // client and the server.
/** /**
* @operation exportJson * @operation export
* @tag Boards * @tag Boards
* *
* @summary This route is used to export the board to a json file format. * @summary This route is used to export the board.
* *
* @description If user is already logged-in, pass loginToken as param * @description If user is already logged-in, pass loginToken as param
* "authToken": '/api/boards/:boardId/export?authToken=:token' * "authToken": '/api/boards/:boardId/export?authToken=:token'
@ -47,52 +46,199 @@ if (Meteor.isServer) {
JsonRoutes.sendResult(res, 403); JsonRoutes.sendResult(res, 403);
} }
}); });
}
/**
* @operation exportCSV/TSV // exporter maybe is broken since Gridfs introduced, add fs and path
* @tag Boards
* export class Exporter {
* @summary This route is used to export the board to a CSV or TSV file format. constructor(boardId) {
* this._boardId = boardId;
* @description If user is already logged-in, pass loginToken as param }
*
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/ build() {
* for detailed explanations const fs = Npm.require('fs');
* const os = Npm.require('os');
* @param {string} boardId the ID of the board we are exporting const path = Npm.require('path');
* @param {string} authToken the loginToken
* @param {string} delimiter delimiter to use while building export. Default is comma ',' const byBoard = { boardId: this._boardId };
*/ const byBoardNoLinked = {
Picker.route('/api/boards/:boardId/export/csv', function(params, req, res) { boardId: this._boardId,
const boardId = params.boardId; linkedId: { $in: ['', null] },
let user = null; };
const loginToken = params.query.authToken; // we do not want to retrieve boardId in related elements
if (loginToken) { const noBoardId = {
const hashToken = Accounts._hashLoginToken(loginToken); fields: {
user = Meteor.users.findOne({ boardId: 0,
'services.resume.loginTokens.hashedToken': hashToken, },
}); };
} else if (!Meteor.settings.public.sandstorm) { const result = {
Authentication.checkUserId(req.userId); _format: 'wekan-board-1.0.0',
user = Users.findOne({ };
_id: req.userId, _.extend(
isAdmin: true, result,
}); Boards.findOne(this._boardId, {
} fields: {
const exporter = new Exporter(boardId); stars: 0,
if (exporter.canExport(user)) { },
body = params.query.delimiter }),
? exporter.buildCsv(params.query.delimiter) );
: exporter.buildCsv(); result.lists = Lists.find(byBoard, noBoardId).fetch();
res.writeHead(200, { result.cards = Cards.find(byBoardNoLinked, noBoardId).fetch();
'Content-Length': body[0].length, result.swimlanes = Swimlanes.find(byBoard, noBoardId).fetch();
'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv', result.customFields = CustomFields.find(
}); { boardIds: { $in: [this.boardId] } },
res.write(body[0]); { fields: { boardId: 0 } },
res.end(); ).fetch();
} else { result.comments = CardComments.find(byBoard, noBoardId).fetch();
res.writeHead(403); result.activities = Activities.find(byBoard, noBoardId).fetch();
res.end('Permission Error'); result.rules = Rules.find(byBoard, noBoardId).fetch();
} result.checklists = [];
}); result.checklistItems = [];
result.subtaskItems = [];
result.triggers = [];
result.actions = [];
result.cards.forEach(card => {
result.checklists.push(
...Checklists.find({
cardId: card._id,
}).fetch(),
);
result.checklistItems.push(
...ChecklistItems.find({
cardId: card._id,
}).fetch(),
);
result.subtaskItems.push(
...Cards.find({
parentId: card._id,
}).fetch(),
);
});
result.rules.forEach(rule => {
result.triggers.push(
...Triggers.find(
{
_id: rule.triggerId,
},
noBoardId,
).fetch(),
);
result.actions.push(
...Actions.find(
{
_id: rule.actionId,
},
noBoardId,
).fetch(),
);
});
// [Old] for attachments we only export IDs and absolute url to original doc
// [New] Encode attachment to base64
const getBase64Data = function(doc, callback) {
let buffer = Buffer.allocUnsafe(0);
buffer.fill(0);
// callback has the form function (err, res) {}
const tmpFile = path.join(
os.tmpdir(),
`tmpexport${process.pid}${Math.random()}`,
);
const tmpWriteable = fs.createWriteStream(tmpFile);
const readStream = fs.createReadStream(doc.path);
readStream.on('data', function(chunk) {
buffer = Buffer.concat([buffer, chunk]);
});
readStream.on('error', function(err) {
callback(null, null);
});
readStream.on('end', function() {
// done
fs.unlink(tmpFile, () => {
//ignored
});
callback(null, buffer.toString('base64'));
});
readStream.pipe(tmpWriteable);
};
const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
result.attachments = Attachments.find({ 'meta.boardId': byBoard.boardId })
.fetch()
.map(attachment => {
let filebase64 = null;
filebase64 = getBase64DataSync(attachment);
return {
_id: attachment._id,
cardId: attachment.meta.cardId,
//url: FlowRouter.url(attachment.url()),
file: filebase64,
name: attachment.name,
type: attachment.type,
};
});
// we also have to export some user data - as the other elements only
// include id but we have to be careful:
// 1- only exports users that are linked somehow to that board
// 2- do not export any sensitive information
const users = {};
result.members.forEach(member => {
users[member.userId] = true;
});
result.lists.forEach(list => {
users[list.userId] = true;
});
result.cards.forEach(card => {
users[card.userId] = true;
if (card.members) {
card.members.forEach(memberId => {
users[memberId] = true;
});
}
});
result.comments.forEach(comment => {
users[comment.userId] = true;
});
result.activities.forEach(activity => {
users[activity.userId] = true;
});
result.checklists.forEach(checklist => {
users[checklist.userId] = true;
});
const byUserIds = {
_id: {
$in: Object.getOwnPropertyNames(users),
},
};
// we use whitelist to be sure we do not expose inadvertently
// some secret fields that gets added to User later.
const userFields = {
fields: {
_id: 1,
username: 1,
'profile.fullname': 1,
'profile.initials': 1,
'profile.avatarUrl': 1,
},
};
result.users = Users.find(byUserIds, userFields)
.fetch()
.map(user => {
// user avatar is stored as a relative url, we export absolute
if ((user.profile || {}).avatarUrl) {
user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
}
return user;
});
return result;
}
canExport(user) {
const board = Boards.findOne(this._boardId);
return board && board.isVisibleBy(user);
}
} }

View file

@ -369,6 +369,7 @@ export class TrelloCreator {
// so we make it server only, and let UI catch up once it is done, forget about latency comp. // so we make it server only, and let UI catch up once it is done, forget about latency comp.
const self = this; const self = this;
if (Meteor.isServer) { if (Meteor.isServer) {
// FIXME: Change to new model
file.attachData(att.url, function(error) { file.attachData(att.url, function(error) {
file.boardId = boardId; file.boardId = boardId;
file.cardId = cardId; file.cardId = cardId;

View file

@ -415,6 +415,7 @@ export class WekanCreator {
const self = this; const self = this;
if (Meteor.isServer) { if (Meteor.isServer) {
if (att.url) { if (att.url) {
// FIXME: Change to new file library
file.attachData(att.url, function(error) { file.attachData(att.url, function(error) {
file.boardId = boardId; file.boardId = boardId;
file.cardId = cardId; file.cardId = cardId;
@ -440,6 +441,7 @@ export class WekanCreator {
} }
}); });
} else if (att.file) { } else if (att.file) {
// FIXME: Change to new file library
file.attachData( file.attachData(
Buffer.from(att.file, 'base64'), Buffer.from(att.file, 'base64'),
{ {

8
package-lock.json generated
View file

@ -1744,6 +1744,14 @@
"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true "dev": true
}, },
"fibers": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fibers/-/fibers-4.0.2.tgz",
"integrity": "sha512-FhICi1K4WZh9D6NC18fh2ODF3EWy1z0gzIdV9P7+s2pRjfRBnCkMDJ6x3bV1DkVymKH8HGrQa/FNOBjYvnJ/tQ==",
"requires": {
"detect-libc": "^1.0.3"
}
},
"figures": { "figures": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",

View file

@ -1,64 +1,64 @@
@ECHO OFF @ECHO OFF
REM NOTE: You can try this to install Meteor on Windows, it works: REM NOTE: You can try this to install Meteor on Windows, it works:
REM https://github.com/zodern/windows-meteor-installer/ REM https://github.com/zodern/windows-meteor-installer/
REM Installing Meteor with Chocolatey does not currently work. REM Installing Meteor with Chocolatey does not currently work.
REM NOTE: THIS .BAT DOES NOT WORK !! REM NOTE: THIS .BAT DOES NOT WORK !!
REM Use instead this webpage instructions to build on Windows: REM Use instead this webpage instructions to build on Windows:
REM https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows REM https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows
REM Please add fix PRs, like config of MongoDB etc. REM Please add fix PRs, like config of MongoDB etc.
md C:\repos md C:\repos
cd C:\repos cd C:\repos
REM Install chocolatey REM Install chocolatey
@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin" @"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
choco install -y git curl python2 dotnet4.5.2 nano mongodb-3 mongoclient meteor choco install -y git curl python2 dotnet4.5.2 nano mongodb-3 mongoclient meteor
curl -O https://nodejs.org/dist/v12.16.3/node-v12.16.2-x64.msi curl -O https://nodejs.org/dist/v12.16.3/node-v12.16.2-x64.msi
call node-v12.16.3-x64.msi call node-v12.16.3-x64.msi
call npm config -g set msvs_version 2015 call npm config -g set msvs_version 2015
call meteor npm config -g set msvs_version 2015 call meteor npm config -g set msvs_version 2015
call npm -g install npm call npm -g install npm
call npm -g install node-gyp call npm -g install node-gyp
call npm -g install fibers call npm -g install fibers
cd C:\repos cd C:\repos
git clone https://github.com/wekan/wekan.git git clone https://github.com/wekan/wekan.git
cd wekan cd wekan
git checkout edge git checkout edge
echo "Building Wekan." echo "Building Wekan."
REM del /S /F /Q packages REM del /S /F /Q packages
REM ## REPOS BELOW ARE INCLUDED TO WEKAN REM ## REPOS BELOW ARE INCLUDED TO WEKAN
REM md packages REM md packages
REM cd packages REM cd packages
REM git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router REM git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router
REM git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core REM git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core
REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git
REM git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git REM git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git
REM git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git REM git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git
REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git
REM git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git REM git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git
REM move meteor-accounts-oidc/packages/switch_accounts-oidc wekan_accounts-oidc REM move meteor-accounts-oidc/packages/switch_accounts-oidc wekan_accounts-oidc
REM move meteor-accounts-oidc/packages/switch_oidc wekan_oidc REM move meteor-accounts-oidc/packages/switch_oidc wekan_oidc
REM del /S /F /Q meteor-accounts-oidc REM del /S /F /Q meteor-accounts-oidc
REM sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' ~/repos/wekan/packages/meteor-useraccounts-core/package.js REM sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' ~/repos/wekan/packages/meteor-useraccounts-core/package.js
cd .. cd ..
REM del /S /F /Q node_modules REM del /S /F /Q node_modules
call meteor npm install call meteor npm install
REM del /S /F /Q .build REM del /S /F /Q .build
call meteor build .build --directory call meteor build .build --directory
copy fix-download-unicode\cfs_access-point.txt .build\bundle\programs\server\packages\cfs_access-point.js copy fix-download-unicode\cfs_access-point.txt .build\bundle\programs\server\packages\cfs_access-point.js
REM ## Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc. REM ## Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
del /S /F /Q rm .build/bundle/programs/web.browser.legacy del /S /F /Q rm .build/bundle/programs/web.browser.legacy
REM ## Install some NPM packages REM ## Install some NPM packages
cd .build\bundle\programs\server cd .build\bundle\programs\server
call meteor npm install call meteor npm install
REM cd C:\repos\wekan\.meteor\local\build\programs\server REM cd C:\repos\wekan\.meteor\local\build\programs\server
REM del node_modules REM del node_modules
cd C:\repos\wekan cd C:\repos\wekan
call start-wekan.bat call start-wekan.bat

View file

@ -261,6 +261,7 @@ const myCommand :Spk.Manifest.Command = (
(key = "LDAP_ENABLE", value="false"), (key = "LDAP_ENABLE", value="false"),
(key = "PASSWORD_LOGIN_ENABLED", value="true"), (key = "PASSWORD_LOGIN_ENABLED", value="true"),
(key = "SANDSTORM", value="1"), (key = "SANDSTORM", value="1"),
(key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}") (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}"),
(key = "ATTACHMENTS_STORE_PATH", value = "/var/attachments/")
] ]
); );

View file

@ -80,7 +80,7 @@ Migrations.add('lowercase-board-permission', () => {
Migrations.add('change-attachments-type-for-non-images', () => { Migrations.add('change-attachments-type-for-non-images', () => {
const newTypeForNonImage = 'application/octet-stream'; const newTypeForNonImage = 'application/octet-stream';
Attachments.find().forEach(file => { Attachments.find().forEach(file => {
if (!file.isImage()) { if (!file.isImage) {
Attachments.update( Attachments.update(
file._id, file._id,
{ {
@ -1044,3 +1044,46 @@ Migrations.add('add-sort-field-to-boards', () => {
} }
}); });
}); });
import { MongoInternals } from 'meteor/mongo';
Migrations.add('change-attachment-library', () => {
const fs = require('fs');
CFSAttachments.find().forEach(file => {
const bucket = new MongoInternals.NpmModule.GridFSBucket(MongoInternals.defaultRemoteCollectionDriver().mongo.db, {bucketName: 'cfs_gridfs.attachments'});
const gfsId = new MongoInternals.NpmModule.ObjectID(file.copies.attachments.key);
const reader = bucket.openDownloadStream(gfsId);
let store = Attachments.storagePath();
if (store.charAt(store.length - 1) === '/') {
store = store.substring(0, store.length - 1);
}
const path = `${store}/${file.name()}`;
const fd = fs.createWriteStream(path);
reader.pipe(fd);
reader.on('end', () => {
let opts = {
fileName: file.name(),
type: file.type(),
size: file.size(),
fileId: file._id,
meta: {
userId: file.userId,
boardId: file.boardId,
cardId: file.cardId
}
};
if (file.listId) {
opts.meta.listId = file.listId;
}
if (file.swimlaneId) {
opts.meta.swimlaneId = file.swimlaneId;
}
Attachments.addFile(path, opts, (err, fileRef) => {
if (err) {
console.log('error when migrating', file.name(), err);
}
});
});
});
});

View file

@ -0,0 +1,212 @@
const localFSStore = process.env.ATTACHMENTS_STORE_PATH;
const storeName = 'attachments';
const defaultStoreOptions = {
beforeWrite: fileObj => {
if (!fileObj.isImage()) {
return {
type: 'application/octet-stream',
};
}
return {};
},
};
let store;
if (localFSStore) {
// have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem
const fs = Npm.require('fs');
const path = Npm.require('path');
const mongodb = Npm.require('mongodb');
const Grid = Npm.require('gridfs-stream');
// calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :(
let pathname = localFSStore;
/*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */
if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) {
pathname = path.join(
__meteor_bootstrap__.serverDir,
`../../../cfs/files/${storeName}`,
);
}
if (!pathname)
throw new Error('FS.Store.FileSystem unable to determine path');
// Check if we have '~/foo/bar'
if (pathname.split(path.sep)[0] === '~') {
const homepath =
process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
if (homepath) {
pathname = pathname.replace('~', homepath);
} else {
throw new Error('FS.Store.FileSystem unable to resolve "~" in path');
}
}
// Set absolute path
const absolutePath = path.resolve(pathname);
const _FStore = new FS.Store.FileSystem(storeName, {
path: localFSStore,
...defaultStoreOptions,
});
const GStore = {
fileKey(fileObj) {
const key = {
_id: null,
filename: null,
};
// If we're passed a fileObj, we retrieve the _id and filename from it.
if (fileObj) {
const info = fileObj._getInfo(storeName, {
updateFileRecordFirst: false,
});
key._id = info.key || null;
key.filename =
info.name ||
fileObj.name({ updateFileRecordFirst: false }) ||
`${fileObj.collectionName}-${fileObj._id}`;
}
// If key._id is null at this point, createWriteStream will let GridFS generate a new ID
return key;
},
db: undefined,
mongoOptions: { useNewUrlParser: true },
mongoUrl: process.env.MONGO_URL,
init() {
this._init(err => {
this.inited = !err;
});
},
_init(callback) {
const self = this;
mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function(
err,
db,
) {
if (err) {
return callback(err);
}
self.db = db;
return callback(null);
});
return;
},
createReadStream(fileKey, options) {
const self = this;
if (!self.inited) {
self.init();
return undefined;
}
options = options || {};
// Init GridFS
const gfs = new Grid(self.db, mongodb);
// Set the default streamning settings
const settings = {
_id: new mongodb.ObjectID(fileKey._id),
root: `cfs_gridfs.${storeName}`,
};
// Check if this should be a partial read
if (
typeof options.start !== 'undefined' &&
typeof options.end !== 'undefined'
) {
// Add partial info
settings.range = {
startPos: options.start,
endPos: options.end,
};
}
return gfs.createReadStream(settings);
},
};
GStore.init();
const CRS = 'createReadStream';
const _CRS = `_${CRS}`;
const FStore = _FStore._transform;
FStore[_CRS] = FStore[CRS].bind(FStore);
FStore[CRS] = function(fileObj, options) {
let stream;
try {
const localFile = path.join(
absolutePath,
FStore.storage.fileKey(fileObj),
);
const state = fs.statSync(localFile);
if (state) {
stream = FStore[_CRS](fileObj, options);
}
} catch (e) {
// file is not there, try GridFS ?
stream = undefined;
}
if (stream) return stream;
else {
try {
const stream = GStore[CRS](GStore.fileKey(fileObj), options);
return stream;
} catch (e) {
return undefined;
}
}
}.bind(FStore);
store = _FStore;
} else {
store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, {
// XXX Add a new store for cover thumbnails so we don't load big images in
// the general board view
// If the uploaded document is not an image we need to enforce browser
// download instead of execution. This is particularly important for HTML
// files that the browser will just execute if we don't serve them with the
// appropriate `application/octet-stream` MIME header which can lead to user
// data leaks. I imagine other formats (like PDF) can also be attack vectors.
// See https://github.com/wekan/wekan/issues/99
// XXX Should we use `beforeWrite` option of CollectionFS instead of
// collection-hooks?
// We should use `beforeWrite`.
...defaultStoreOptions,
});
}
CFSAttachments = new FS.Collection('attachments', {
stores: [store],
});
if (Meteor.isServer) {
Meteor.startup(() => {
CFSAttachments.files._ensureIndex({ cardId: 1 });
});
CFSAttachments.allow({
insert(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
},
update(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
},
remove(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
},
// We authorize the attachment download either:
// - if the board is public, everyone (even unconnected) can download it
// - if the board is private, only board members can download it
download(userId, doc) {
if (Meteor.isServer) {
return true;
}
const board = Boards.findOne(doc.boardId);
if (board.isPublic()) {
return true;
} else {
return board.hasMember(userId);
}
},
fetch: ['boardId'],
});
}
export default CFSAttachments;

View file

@ -131,7 +131,7 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
// Gather queries and send in bulk // Gather queries and send in bulk
const cardComments = this.join(CardComments); const cardComments = this.join(CardComments);
cardComments.selector = _ids => ({ cardId: _ids }); cardComments.selector = _ids => ({ cardId: _ids });
const attachments = this.join(Attachments); const attachments = this.join(Attachments.collection);
attachments.selector = _ids => ({ cardId: _ids }); attachments.selector = _ids => ({ cardId: _ids });
const checklists = this.join(Checklists); const checklists = this.join(Checklists);
checklists.selector = _ids => ({ cardId: _ids }); checklists.selector = _ids => ({ cardId: _ids });