mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30:13 +01:00
Merge branch 'lib-change' of https://github.com/PDIS/wekan into
PDIS-lib-change
This commit is contained in:
commit
055b528532
24 changed files with 823 additions and 442 deletions
|
|
@ -98,3 +98,4 @@ percolate:synced-cron
|
||||||
easylogic:summernote
|
easylogic:summernote
|
||||||
cfs:filesystem
|
cfs:filesystem
|
||||||
ostrio:cookies
|
ostrio:cookies
|
||||||
|
ostrio:files
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,7 @@ Template.minicard.helpers({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
coverUrl() {
|
||||||
|
return Attachments.findOne(this.coverId).link('original', '/');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": "所有動作將從活動來源中刪除,您將無法恢復泳道。此操作無法還原。",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
248
models/export.js
248
models/export.js
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
8
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/")
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
212
server/old-attachments-migration.js
Normal file
212
server/old-attachments-migration.js
Normal 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;
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue