Merge branch 'feature-ostrio-files' of https://github.com/majus/wekan

pull/4401/head
Lauri Ojansivu 3 years ago
commit d00596f88a
  1. 2
      .devcontainer/Dockerfile
  2. 2
      .devcontainer/docker-compose.yml
  3. 11
      .meteor/packages
  4. 39
      .meteor/versions
  5. 3
      Dockerfile
  6. 6
      client/components/activities/activities.js
  7. 19
      client/components/cards/attachments.jade
  8. 140
      client/components/cards/attachments.js
  9. 5
      client/components/cards/attachments.styl
  10. 2
      client/components/cards/minicard.jade
  11. 6
      client/components/cards/minicard.js
  12. 61
      client/components/main/editor.js
  13. 4
      client/components/users/userAvatar.jade
  14. 56
      client/components/users/userAvatar.js
  15. 34
      client/lib/utils.js
  16. 8
      docker-compose.yml
  17. 914
      fix-download-unicode/cfs_access-point.txt
  18. 12
      models/activities.js
  19. 324
      models/attachments.js
  20. 116
      models/attachments_old.js
  21. 70
      models/avatars.js
  22. 29
      models/avatars_old.js
  23. 1
      models/boards.js
  24. 10
      models/cards.js
  25. 10
      models/exporter.js
  26. 47
      models/lib/fsHooks/createInterceptDownload.js
  27. 17
      models/lib/fsHooks/createOnAfterRemove.js
  28. 51
      models/lib/fsHooks/createOnAfterUpload.js
  29. 9
      models/lib/grid/createBucket.js
  30. 4
      models/lib/grid/createObjectId.js
  31. 60
      models/trelloCreator.js
  32. 93
      models/wekanCreator.js
  33. 1
      rebuild-wekan.bat
  34. 1
      releases/rebuild-release.sh
  35. 155
      server/migrations.js
  36. 3
      server/publications/avatars.js
  37. 4
      server/publications/boards.js
  38. 2
      server/publications/notifications.js
  39. 6
      snap-src/bin/config
  40. 6
      snap-src/bin/wekan-help
  41. 2
      stacksmith/user-scripts/build.sh
  42. 4
      torodb-postgresql/docker-compose.yml

@ -1,4 +1,4 @@
FROM quay.io/wekan/ubuntu:groovy-20210115
FROM ubuntu:rolling
LABEL maintainer="sgr"
ENV BUILD_DEPS="gnupg gosu libarchive-tools wget curl bzip2 g++ build-essential python git ca-certificates iproute2"

@ -33,10 +33,12 @@ services:
- WITH_API=true
- RICHER_CARD_COMMENT_EDITOR=true
- BROWSER_POLICY_ENABLED=true
- WRITABLE_PATH=/data
depends_on:
- wekandb-dev
volumes:
- /etc/localtime:/etc/localtime:ro
- ./volumes/data:/data
- ../client:/home/wekan/app/client
- ../models:/home/wekan/app/models
- ../config:/home/wekan/app/config

@ -17,7 +17,7 @@ es5-shim@4.8.0
# Collections
aldeed:collection2
wekan-cfs-standard-packages
cfs:standard-packages
cottz:publish-relations
dburles:collection-helpers
idmontie:migrations
@ -73,8 +73,8 @@ email@2.0.0
horka:swipebox
dynamic-import@0.6.0
accounts-password@1.7.0
wekan-cfs-gridfs
accounts-password@1.6.2
cfs:gridfs
rzymek:fullcalendar
momentjs:moment@2.22.2
browser-policy-framing@1.1.0
@ -89,7 +89,10 @@ meteorhacks:aggregate@1.3.0
wekan-markdown
konecty:mongo-counter
percolate:synced-cron
wekan-cfs-filesystem
cfs:filesystem
ostrio:cookies
ostrio:files@2.0.1
tmeasday:check-npm-versions
steffo:meteor-accounts-saml
rajit:bootstrap3-datepicker-fi
rajit:bootstrap3-datepicker-ar

@ -23,7 +23,24 @@ browser-policy-framing@1.1.0
caching-compiler@1.2.2
caching-html-compiler@1.2.0
callback-hook@1.3.0
cfs:access-point@0.1.49
cfs:base-package@0.0.30
cfs:collection@0.5.5
cfs:collection-filters@0.2.4
cfs:data-man@0.0.6
cfs:file@0.1.17
cfs:filesystem@0.1.2
cfs:gridfs@0.0.34
cfs:http-methods@0.0.32
cfs:http-publish@0.0.13
cfs:power-queue@0.9.11
cfs:reactive-list@0.0.9
cfs:reactive-property@0.0.4
cfs:standard-packages@0.5.10
cfs:storage-adapter@0.2.4
cfs:tempstore@0.1.6
cfs:upload-http@0.0.20
cfs:worker@0.1.5
check@1.3.1
chuangbo:cookie@1.1.0
coagmano:stylus@1.1.0
@ -117,6 +134,8 @@ oauth2@1.3.0
observe-sequence@1.0.16
ongoworks:speakingurl@1.1.0
ordered-dict@1.1.0
ostrio:cookies@2.7.0
ostrio:files@2.0.1
pascoual:pdfkit@1.0.7
peerlibrary:assert@0.3.0
peerlibrary:base-component@0.16.0
@ -211,8 +230,10 @@ templating@1.4.0
templating-compiler@1.4.1
templating-runtime@1.4.0
templating-tools@1.2.0
tmeasday:check-npm-versions@1.0.2
tracker@1.2.0
twbs:bootstrap@3.3.6
typescript@4.2.2
ui@1.0.13
underscore@1.0.10
url@1.3.2
@ -224,24 +245,6 @@ webapp-hashing@1.1.0
wekan-accounts-cas@0.1.0
wekan-accounts-lockout@1.0.0
wekan-accounts-oidc@1.0.10
wekan-cfs-access-point@0.1.50
wekan-cfs-base-package@0.0.30
wekan-cfs-collection@0.5.5
wekan-cfs-collection-filters@0.2.4
wekan-cfs-data-man@0.0.6
wekan-cfs-file@0.1.17
wekan-cfs-filesystem@0.1.2
wekan-cfs-gridfs@0.0.34
wekan-cfs-http-methods@0.0.32
wekan-cfs-http-publish@0.0.13
wekan-cfs-power-queue@0.9.11
wekan-cfs-reactive-list@0.0.9
wekan-cfs-reactive-property@0.0.4
wekan-cfs-standard-packages@0.5.10
wekan-cfs-storage-adapter@0.2.4
wekan-cfs-tempstore@0.1.6
wekan-cfs-upload-http@0.0.21
wekan-cfs-worker@0.1.5
wekan-ldap@0.0.2
wekan-markdown@1.0.9
wekan-oidc@1.0.12

@ -31,7 +31,6 @@ ENV BUILD_DEPS="apt-utils libarchive-tools gnupg gosu wget curl bzip2 g++ build-
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90 \
RICHER_CARD_COMMENT_EDITOR=false \
CARD_OPENED_WEBHOOK_ENABLED=false \
ATTACHMENTS_STORE_PATH="" \
MAX_IMAGE_PIXEL="" \
IMAGE_COMPRESS_RATIO="" \
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
@ -290,9 +289,7 @@ RUN \
chmod u+w *.json && \
gosu wekan:wekan npm install && \
gosu wekan:wekan /home/wekan/.meteor/meteor build --directory /home/wekan/app_build && \
#cp /home/wekan/app/fix-download-unicode/cfs_access-point.txt /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
#rm /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/rajit_bootstrap3-datepicker/lib/bootstrap-datepicker/node_modules/phantomjs-prebuilt/lib/phantom/bin/phantomjs && \
#chown wekan /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
#Removed binary version of bcrypt because of security vulnerability that is not fixed yet.
#https://github.com/wekan/wekan/commit/4b2010213907c61b0e0482ab55abb06f6a668eac
#https://github.com/wekan/wekan/commit/7eeabf14be3c63fae2226e561ef8a0c1390c8d3c

@ -196,14 +196,14 @@ BlazeComponent.extendComponent({
// trying to display url before file is stored generates js errors
return (
(attachment &&
attachment.url({ download: true }) &&
attachment.path &&
Blaze.toHTML(
HTML.A(
{
href: attachment.url({ download: true }),
href: `${attachment.link()}?download=true`,
target: '_blank',
},
DOMPurify.sanitize(attachment.name()),
DOMPurify.sanitize(attachment.name),
),
)) ||
DOMPurify.sanitize(this.currentData().activity.attachmentName)

@ -11,9 +11,6 @@ template(name="previewClipboardImagePopup")
img.preview-clipboard-image()
button.primary.js-upload-pasted-image {{_ 'upload'}}
template(name="previewAttachedImagePopup")
img.preview-large-image.js-large-image-clicked(src="{{url}}")
template(name="attachmentDeletePopup")
p {{_ "attachment-delete-pop"}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
@ -22,31 +19,31 @@ template(name="attachmentsGalery")
.attachments-galery
each attachments
.attachment-item
a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}")
a.attachment-thumbnail.swipebox(href="{{link}}" title="{{name}}")
if isUploaded
if isImage
img.attachment-thumbnail-img(src="{{url}}")
img.attachment-thumbnail-img(src="{{link}}")
else if($eq extension 'mp3')
video(width="100%" height="100%" controls="true")
source(src="{{url}}" type="audio/mpeg")
source(src="{{link}}" type="audio/mpeg")
else if($eq extension 'ogg')
video(width="100%" height="100%" controls="true")
source(src="{{url}}" type="video/ogg")
source(src="{{link}}" type="video/ogg")
else if($eq extension 'webm')
video(width="100%" height="100%" controls="true")
source(src="{{url}}" type="video/webm")
source(src="{{link}}" type="video/webm")
else if($eq extension 'mp4')
video(width="100%" height="100%" controls="true")
source(src="{{url}}" type="video/mp4")
source(src="{{link}}" type="video/mp4")
else
span.attachment-thumbnail-ext= extension
else
+spinner
span.attachment-thumbnail-ext= extension
p.attachment-details
= name
span.file-size ({{fileSize size}} KB)
span.attachment-details-actions
a.js-download(href="{{url download=true}}")
a.js-download(href="{{link}}?download=true", download="{{name}}")
i.fa.fa-download
| {{_ 'download'}}
if currentUser.isBoardMember

@ -13,35 +13,10 @@ Template.attachmentsGalery.events({
event.stopPropagation();
},
'click .js-add-cover'() {
Cards.findOne(this.cardId).setCover(this._id);
Cards.findOne(this.meta.cardId).setCover(this._id);
},
'click .js-remove-cover'() {
Cards.findOne(this.cardId).unsetCover();
},
'click .js-preview-image'(event) {
Popup.open('previewAttachedImage').call(this, event);
// when multiple thumbnails, if click one then another very fast,
// we might get a wrong width from previous img.
// when popup reused, onRendered() won't be called, so we cannot get there.
// here make sure to get correct size when this img fully loaded.
const img = $('img.preview-large-image')[0];
if (!img) return;
const rePosPopup = () => {
const w = img.width;
const h = img.height;
// if the image is too large, we resize & center the popup.
if (w > 300) {
$('div.pop-over').css({
width: w + 20,
position: 'absolute',
left: (window.innerWidth - w) / 2,
top: (window.innerHeight - h) / 2,
});
}
};
const url = $(event.currentTarget).attr('src');
if (img.src === url && img.complete) rePosPopup();
else img.onload = rePosPopup;
Cards.findOne(this.meta.cardId).unsetCover();
},
});
@ -54,59 +29,30 @@ Template.attachmentsGalery.helpers({
},
});
Template.previewAttachedImagePopup.events({
'click .js-large-image-clicked'() {
Popup.back();
},
});
Template.cardAttachmentsPopup.events({
'change .js-attach-file'(event) {
const card = this;
const processFile = f => {
Utils.processUploadedAttachment(card, f, attachment => {
if (attachment && attachment._id && attachment.isImage()) {
card.setCover(attachment._id);
if (event.currentTarget.files && event.currentTarget.files[0]) {
const uploader = Attachments.insert(
{
file: event.currentTarget.files[0],
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
},
false,
);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
card.setCover(fileRef._id);
}
}
});
uploader.on('end', (error, fileRef) => {
Popup.back();
});
};
FS.Utility.eachFile(event, f => {
if (
MAX_IMAGE_PIXEL > 0 &&
typeof f.type === 'string' &&
f.type.match(/^image/)
) {
// is image
const reader = new FileReader();
reader.onload = function(e) {
const dataurl = e && e.target && e.target.result;
if (dataurl !== undefined) {
Utils.shrinkImage({
dataurl,
maxSize: MAX_IMAGE_PIXEL,
ratio: COMPRESS_RATIO,
toBlob: true,
callback(blob) {
if (blob === false) {
processFile(f);
} else {
blob.name = f.name;
processFile(blob);
}
},
});
} else {
// couldn't process it let other function handle it?
processFile(f);
}
};
reader.readAsDataURL(f);
} else {
processFile(f);
}
});
uploader.start();
}
},
'click .js-computer-upload'(event, templateInstance) {
templateInstance.find('.js-attach-file').click();
@ -154,30 +100,32 @@ Template.previewClipboardImagePopup.onRendered(() => {
Template.previewClipboardImagePopup.events({
'click .js-upload-pasted-image'() {
const results = pastedResults;
if (results && results.file) {
const card = this;
if (pastedResults && pastedResults.file) {
const file = pastedResults.file;
window.oPasted = pastedResults;
const card = this;
const file = new FS.File(results.file);
if (!results.name) {
// 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') {
file.name(results.file.type.replace('image/', 'clipboard.'));
const uploader = Attachments.insert(
{
file,
meta: Utils.getCommonAttachmentMetaFrom(card),
fileName: file.name || file.type.replace('image/', 'clipboard.'),
chunkSize: 'dynamic',
},
false,
);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
card.setCover(fileRef._id);
}
}
}
file.updatedAt(new Date());
file.boardId = card.boardId;
file.cardId = card._id;
file.userId = Meteor.userId();
const attachment = Attachments.insert(file);
if (attachment && attachment._id && attachment.isImage()) {
card.setCover(attachment._id);
}
pastedResults = null;
$(document.body).pasteImageReader(() => {});
Popup.back();
});
uploader.on('end', (error, fileRef) => {
pastedResults = null;
$(document.body).pasteImageReader(() => {});
Popup.back();
});
uploader.start();
}
},
});

@ -51,11 +51,6 @@
display: block
box-shadow: 0 1px 2px rgba(0,0,0,.2)
.preview-large-image
max-width: 1000px
display: block
box-shadow: 0 1px 2px rgba(0,0,0,.2)
.preview-clipboard-image
width: 280px
max-width: 100%;

@ -7,7 +7,7 @@ template(name="minicard")
.handle
.fa.fa-arrows
if cover
.minicard-cover(style="background-image: url('{{cover.url}}');")
.minicard-cover(style="background-image: url('{{cover.link 'original' '/'}}?dummyReloadAfterSessionEstablished={{sess}}');")
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels

@ -114,6 +114,12 @@ Template.minicard.helpers({
return false;
}
},
// XXX resolve this nasty hack for https://github.com/veliovgroup/Meteor-Files/issues/763
sess() {
return Meteor.connection && Meteor.connection._lastSessionId
? Meteor.connection._lastSessionId
: null;
},
});
BlazeComponent.extendComponent({

@ -153,7 +153,6 @@ BlazeComponent.extendComponent({
});
}
},
onImageUpload(files) {
const $summernote = getSummernote(this);
if (files && files.length > 0) {
@ -161,46 +160,26 @@ BlazeComponent.extendComponent({
const currentCard = Utils.getCurrentCard();
const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
const insertImage = src => {
// process all image upload types to the description/comment window
const img = document.createElement('img');
img.src = src;
img.setAttribute('width', '100%');
$summernote.summernote('insertNode', img);
};
const processData = function(fileObj) {
Utils.processUploadedAttachment(
currentCard,
fileObj,
attachment => {
if (
attachment &&
attachment._id &&
attachment.isImage()
) {
attachment.one('uploaded', function() {
const maxTry = 3;
const checkItvl = 500;
let retry = 0;
const checkUrl = function() {
// even though uploaded event fired, attachment.url() is still null somehow //TODO
const url = attachment.url();
if (url) {
insertImage(
`${location.protocol}//${location.host}${url}`,
);
} else {
retry++;
if (retry < maxTry) {
setTimeout(checkUrl, checkItvl);
}
}
};
checkUrl();
});
}
const processUpload = function(file) {
const uploader = Attachments.insert(
{
file,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
},
false,
);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
const img = document.createElement('img');
img.src = fileRef.link();
img.setAttribute('width', '100%');
$summernote.summernote('insertNode', img);
}
}
});
uploader.start();
};
if (MAX_IMAGE_PIXEL) {
const reader = new FileReader();
@ -216,7 +195,7 @@ BlazeComponent.extendComponent({
callback(blob) {
if (blob !== false) {
blob.name = image.name;
processData(blob);
processUpload(blob);
}
},
});
@ -224,7 +203,7 @@ BlazeComponent.extendComponent({
};
reader.readAsDataURL(image);
} else {
processData(image);
processUpload(image);
}
}
},

@ -85,7 +85,7 @@ template(name="changeAvatarPopup")
each uploadedAvatars
li: a.js-select-avatar
.member
img.avatar.avatar-image(src="{{url avatarUrlOptions}}")
img.avatar.avatar-image(src="{{link}}?auth=false&brokenIsFine=true")
| {{_ 'uploaded-avatar'}}
if isSelected
i.fa.fa-check
@ -93,7 +93,7 @@ template(name="changeAvatarPopup")
unless isSelected
a.js-delete-avatar {{_ 'delete'}}
| -
= original.name
= name
li: a.js-select-initials
.member
+userAvatarInitials(userId=currentUser._id)

@ -3,6 +3,7 @@ import Avatars from '/models/avatars';
import Users from '/models/users';
import Org from '/models/org';
import Team from '/models/team';
import { formatFleURL } from 'meteor/ostrio:files/lib';
Template.userAvatar.helpers({
userData() {
@ -181,21 +182,14 @@ BlazeComponent.extendComponent({
Meteor.subscribe('my-avatars');
},
avatarUrlOptions() {
return {
auth: false,
brokenIsFine: true,
};
},
uploadedAvatars() {
return Avatars.find({ userId: Meteor.userId() });
return Avatars.find({ userId: Meteor.userId() }).each();
},
isSelected() {
const userProfile = Meteor.user().profile;
const avatarUrl = userProfile && userProfile.avatarUrl;
const currentAvatarUrl = this.currentData().url(this.avatarUrlOptions());
const currentAvatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
return avatarUrl === currentAvatarUrl;
},
@ -220,32 +214,30 @@ BlazeComponent.extendComponent({
this.$('.js-upload-avatar-input').click();
},
'change .js-upload-avatar-input'(event) {
let file, fileUrl;
FS.Utility.eachFile(event, f => {
try {
file = Avatars.insert(new FS.File(f));
fileUrl = file.url(this.avatarUrlOptions());
} catch (e) {
this.setError('avatar-too-big');
}
});
if (fileUrl) {
this.setError('');
const fetchAvatarInterval = window.setInterval(() => {
$.ajax({
url: fileUrl,
success: () => {
this.setAvatar(file.url(this.avatarUrlOptions()));
window.clearInterval(fetchAvatarInterval);
},
});
}, 100);
const self = this;
if (event.currentTarget.files && event.currentTarget.files[0]) {
const uploader = Avatars.insert(
{
file: event.currentTarget.files[0],
chunkSize: 'dynamic',
},
false,
);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
self.setAvatar(
`${formatFleURL(fileRef)}?auth=false&brokenIsFine=true`,
);
}
});
uploader.on('error', (error, fileData) => {
self.setError(error.reason);
});
uploader.start();
}
},
'click .js-select-avatar'() {
const avatarUrl = this.currentData().url(this.avatarUrlOptions());
const avatarUrl = `${this.currentData().link()}?auth=false&brokenIsFine=true`;
this.setAvatar(avatarUrl);
},
'click .js-select-initials'() {

@ -162,33 +162,21 @@ Utils = {
})
);
},
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
processUploadedAttachment(card, fileObj, callback) {
const next = attachment => {
if (typeof callback === 'function') {
callback(attachment);
}
};
if (!card) {
return next();
}
const file = new FS.File(fileObj);
getCommonAttachmentMetaFrom(card) {
const meta = {};
if (card.isLinkedCard()) {
file.boardId = Cards.findOne(card.linkedId).boardId;
file.cardId = card.linkedId;
meta.boardId = Cards.findOne(card.linkedId).boardId;
meta.cardId = card.linkedId;
} else {
file.boardId = card.boardId;
file.swimlaneId = card.swimlaneId;
file.listId = card.listId;
file.cardId = card._id;
}
file.userId = Meteor.userId();
if (file.original) {
file.original.name = fileObj.name;
meta.boardId = card.boardId;
meta.swimlaneId = card.swimlaneId;
meta.listId = card.listId;
meta.cardId = card._id;
}
return next(Attachments.insert(file));
return meta;
},
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
shrinkImage(options) {
// shrink image to certain size
const dataurl = options.dataurl,

@ -247,10 +247,6 @@ services:
# Defaults below. Uncomment to change. wekan/server/accounts-common.js
# - ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90
#---------------------------------------------------------------
# ==== STORE ATTACHMENT ON SERVER FILESYSTEM INSTEAD OF MONGODB ====
# https://github.com/wekan/wekan/pull/2603
#- ATTACHMENTS_STORE_PATH = <pathname> # pathname can be relative or fullpath
#---------------------------------------------------------------
# ==== RICH TEXT EDITOR IN CARD COMMENTS ====
# https://github.com/wekan/wekan/pull/2560
- RICHER_CARD_COMMENT_EDITOR=false
@ -329,6 +325,9 @@ services:
# When browser policy is enabled, HTML code at this Trusted URL can have iframe that embeds Wekan inside.
#- TRUSTED_URL=https://intra.example.com
#-----------------------------------------------------------------
# ==== WRITEABLE PATH FOR FILE UPLOADS ====
- WRITABLE_PATH=/data
#-----------------------------------------------------------------
# ==== OUTGOING WEBHOOKS ====
# What to send to Outgoing Webhook, or leave out. If commented out the default values will be: cardId,listId,oldListId,boardId,comment,user,card,commentId,swimlaneId,customerField,customFieldValue
#- WEBHOOKS_ATTRIBUTES=cardId,listId,oldListId,boardId,comment,user,card,commentId
@ -674,6 +673,7 @@ services:
- wekandb
volumes:
- /etc/localtime:/etc/localtime:ro
- ./volumes/data:/data
#---------------------------------------------------------------------------------
# ==== OPTIONAL: SHARE DATABASE TO OFFICE LAN AND REMOTE VPN ====

@ -1,914 +0,0 @@
(function () {
/* Imports */
var Meteor = Package.meteor.Meteor;
var global = Package.meteor.global;
var meteorEnv = Package.meteor.meteorEnv;
var FS = Package['wekan-cfs-base-package'].FS;
var check = Package.check.check;
var Match = Package.check.Match;
var EJSON = Package.ejson.EJSON;
var HTTP = Package['wekan-cfs-http-methods'].HTTP;
/* Package-scope variables */
var rootUrlPathPrefix, baseUrl, getHeaders, getHeadersByCollection, _existingMountPoints, mountUrls;
(function(){
///////////////////////////////////////////////////////////////////////
// //
// packages/cfs_access-point/packages/cfs_access-point.js //
// //
///////////////////////////////////////////////////////////////////////
//
(function () {
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //
// packages/wekan-cfs-access-point/access-point-common.js //
// //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
rootUrlPathPrefix = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || ""; // 1
// Adjust the rootUrlPathPrefix if necessary // 2
if (rootUrlPathPrefix.length > 0) { // 3
if (rootUrlPathPrefix.slice(0, 1) !== '/') { // 4
rootUrlPathPrefix = '/' + rootUrlPathPrefix; // 5
} // 6
if (rootUrlPathPrefix.slice(-1) === '/') { // 7
rootUrlPathPrefix = rootUrlPathPrefix.slice(0, -1); // 8
} // 9
} // 10
// 11
// prepend ROOT_URL when isCordova // 12
if (Meteor.isCordova) { // 13
rootUrlPathPrefix = Meteor.absoluteUrl(rootUrlPathPrefix.replace(/^\/+/, '')).replace(/\/+$/, ''); // 14
} // 15
// 16
baseUrl = '/cfs'; // 17
FS.HTTP = FS.HTTP || {}; // 18
// 19
// Note the upload URL so that client uploader packages know what it is // 20
FS.HTTP.uploadUrl = rootUrlPathPrefix + baseUrl + '/files'; // 21
// 22
/** // 23
* @method FS.HTTP.setBaseUrl // 24
* @public // 25
* @param {String} newBaseUrl - Change the base URL for the HTTP GET and DELETE endpoints. // 26
* @returns {undefined} // 27
*/ // 28
FS.HTTP.setBaseUrl = function setBaseUrl(newBaseUrl) { // 29
// 30
// Adjust the baseUrl if necessary // 31
if (newBaseUrl.slice(0, 1) !== '/') { // 32
newBaseUrl = '/' + newBaseUrl; // 33
} // 34
if (newBaseUrl.slice(-1) === '/') { // 35
newBaseUrl = newBaseUrl.slice(0, -1); // 36
} // 37
// 38
// Update the base URL // 39
baseUrl = newBaseUrl; // 40
// 41
// Change the upload URL so that client uploader packages know what it is // 42
FS.HTTP.uploadUrl = rootUrlPathPrefix + baseUrl + '/files'; // 43
// 44
// Remount URLs with the new baseUrl, unmounting the old, on the server only. // 45
// If existingMountPoints is empty, then we haven't run the server startup // 46
// code yet, so this new URL will be used at that point for the initial mount. // 47
if (Meteor.isServer && !FS.Utility.isEmpty(_existingMountPoints)) { // 48
mountUrls(); // 49
} // 50
}; // 51
// 52
/* // 53
* FS.File extensions // 54
*/ // 55
// 56
/** // 57
* @method FS.File.prototype.url Construct the file url // 58
* @public // 59
* @param {Object} [options] // 60
* @param {String} [options.store] Name of the store to get from. If not defined, the first store defined in `options.stores` for the collection on the client is used.
* @param {Boolean} [options.auth=null] Add authentication token to the URL query string? By default, a token for the current logged in user is added on the client. Set this to `false` to omit the token. Set this to a string to provide your own token. Set this to a number to specify an expiration time for the token in seconds.
* @param {Boolean} [options.download=false] Should headers be set to force a download? Typically this means that clicking the link with this URL will download the file to the user's Downloads folder instead of displaying the file in the browser.
* @param {Boolean} [options.brokenIsFine=false] Return the URL even if we know it's currently a broken link because the file hasn't been saved in the requested store yet.
* @param {Boolean} [options.metadata=false] Return the URL for the file metadata access point rather than the file itself.
* @param {String} [options.uploading=null] A URL to return while the file is being uploaded. // 66
* @param {String} [options.storing=null] A URL to return while the file is being stored. // 67
* @param {String} [options.filename=null] Override the filename that should appear at the end of the URL. By default it is the name of the file in the requested store.
* // 69
* Returns the HTTP URL for getting the file or its metadata. // 70
*/ // 71
FS.File.prototype.url = function(options) { // 72
var self = this; // 73
options = options || {}; // 74
options = FS.Utility.extend({ // 75
store: null, // 76
auth: null, // 77
download: false, // 78
metadata: false, // 79
brokenIsFine: false, // 80
uploading: null, // return this URL while uploading // 81
storing: null, // return this URL while storing // 82
filename: null // override the filename that is shown to the user // 83
}, options.hash || options); // check for "hash" prop if called as helper // 84
// 85
// Primarily useful for displaying a temporary image while uploading an image // 86
if (options.uploading && !self.isUploaded()) { // 87
return options.uploading; // 88
} // 89
// 90
if (self.isMounted()) { // 91
// See if we've stored in the requested store yet // 92
var storeName = options.store || self.collection.primaryStore.name; // 93
if (!self.hasStored(storeName)) { // 94
if (options.storing) { // 95
return options.storing; // 96
} else if (!options.brokenIsFine) { // 97
// We want to return null if we know the URL will be a broken // 98
// link because then we can avoid rendering broken links, broken // 99
// images, etc. // 100
return null; // 101
} // 102
} // 103
// 104
// Add filename to end of URL if we can determine one // 105
var filename = options.filename || self.name({store: storeName}); // 106
if (typeof filename === "string" && filename.length) { // 107
filename = '/' + filename; // 108
} else { // 109
filename = ''; // 110
} // 111
// 112
// TODO: Could we somehow figure out if the collection requires login? // 113
var authToken = ''; // 114
if (Meteor.isClient && typeof Accounts !== "undefined" && typeof Accounts._storedLoginToken === "function") { // 115
if (options.auth !== false) { // 116
// Add reactive deps on the user // 117
Meteor.userId(); // 118
// 119
var authObject = { // 120
authToken: Accounts._storedLoginToken() || '' // 121
}; // 122
// 123
// If it's a number, we use that as the expiration time (in seconds) // 124
if (options.auth === +options.auth) { // 125
authObject.expiration = FS.HTTP.now() + options.auth * 1000; // 126
} // 127
// 128
// Set the authToken // 129
var authString = JSON.stringify(authObject); // 130
authToken = FS.Utility.btoa(authString); // 131
} // 132
} else if (typeof options.auth === "string") { // 133
// If the user supplies auth token the user will be responsible for // 134
// updating // 135
authToken = options.auth; // 136
} // 137
// 138
// Construct query string // 139
var params = {}; // 140
if (authToken !== '') { // 141
params.token = authToken; // 142
} // 143
if (options.download) { // 144
params.download = true; // 145
} // 146
if (options.store) { // 147
// We use options.store here instead of storeName because we want to omit the queryString // 148
// whenever possible, allowing users to have "clean" URLs if they want. The server will // 149
// assume the first store defined on the server, which means that we are assuming that // 150
// the first on the client is also the first on the server. If that's not the case, the // 151
// store option should be supplied. // 152
params.store = options.store; // 153
} // 154
var queryString = FS.Utility.encodeParams(params); // 155
if (queryString.length) { // 156
queryString = '?' + queryString; // 157
} // 158
// 159
// Determine which URL to use // 160
var area; // 161
if (options.metadata) { // 162
area = '/record'; // 163
} else { // 164
area = '/files'; // 165
} // 166
// 167
// Construct and return the http method url // 168
return rootUrlPathPrefix + baseUrl + area + '/' + self.collection.name + '/' + self._id + filename + queryString; // 169
} // 170
// 171
}; // 172
// 173
// 174
// 175
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}).call(this);
(function () {
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //
// packages/wekan-cfs-access-point/access-point-handlers.js //
// //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
getHeaders = []; // 1
getHeadersByCollection = {}; // 2
// 3
FS.HTTP.Handlers = {}; // 4
// 5
/** // 6
* @method FS.HTTP.Handlers.Del // 7
* @public // 8
* @returns {any} response // 9
* // 10
* HTTP DEL request handler // 11
*/ // 12
FS.HTTP.Handlers.Del = function httpDelHandler(ref) { // 13
var self = this; // 14
var opts = FS.Utility.extend({}, self.query || {}, self.params || {}); // 15
// 16
// If DELETE request, validate with 'remove' allow/deny, delete the file, and return // 17
FS.Utility.validateAction(ref.collection.files._validators['remove'], ref.file, self.userId); // 18
// 19
/* // 20
* From the DELETE spec: // 21
* A successful response SHOULD be 200 (OK) if the response includes an // 22
* entity describing the status, 202 (Accepted) if the action has not // 23
* yet been enacted, or 204 (No Content) if the action has been enacted // 24
* but the response does not include an entity. // 25
*/ // 26
self.setStatusCode(200); // 27
// 28
return { // 29
deleted: !!ref.file.remove() // 30
}; // 31
}; // 32
// 33
/** // 34
* @method FS.HTTP.Handlers.GetList // 35
* @public // 36
* @returns {Object} response // 37
* // 38
* HTTP GET file list request handler // 39
*/ // 40
FS.HTTP.Handlers.GetList = function httpGetListHandler() { // 41
// Not Yet Implemented // 42
// Need to check publications and return file list based on // 43
// what user is allowed to see // 44
}; // 45
// 46
/* // 47
requestRange will parse the range set in request header - if not possible it // 48
will throw fitting errors and autofill range for both partial and full ranges // 49
// 50
throws error or returns the object: // 51
{ // 52
start // 53
end // 54
length // 55
unit // 56
partial // 57
} // 58
*/ // 59
var requestRange = function(req, fileSize) { // 60
if (req) { // 61
if (req.headers) { // 62
var rangeString = req.headers.range; // 63
// 64
// Make sure range is a string // 65
if (rangeString === ''+rangeString) { // 66
// 67
// range will be in the format "bytes=0-32767" // 68
var parts = rangeString.split('='); // 69
var unit = parts[0]; // 70
// 71
// Make sure parts consists of two strings and range is of type "byte" // 72
if (parts.length == 2 && unit == 'bytes') { // 73
// Parse the range // 74
var range = parts[1].split('-'); // 75
var start = Number(range[0]); // 76
var end = Number(range[1]); // 77
// 78
// Fix invalid ranges? // 79
if (range[0] != start) start = 0; // 80
if (range[1] != end || !end) end = fileSize - 1; // 81
// 82
// Make sure range consists of a start and end point of numbers and start is less than end // 83
if (start < end) { // 84
// 85
var partSize = 0 - start + end + 1; // 86
// 87
// Return the parsed range // 88
return { // 89
start: start, // 90
end: end, // 91
length: partSize, // 92
size: fileSize, // 93
unit: unit, // 94
partial: (partSize < fileSize) // 95
}; // 96
// 97
} else { // 98
throw new Meteor.Error(416, "Requested Range Not Satisfiable"); // 99
} // 100
// 101
} else { // 102
// The first part should be bytes // 103
throw new Meteor.Error(416, "Requested Range Unit Not Satisfiable"); // 104
} // 105
// 106
} else { // 107
// No range found // 108
} // 109
// 110
} else { // 111
// throw new Error('No request headers set for _parseRange function'); // 112
} // 113
} else { // 114
throw new Error('No request object passed to _parseRange function'); // 115
} // 116
// 117
return { // 118
start: 0, // 119
end: fileSize - 1, // 120
length: fileSize, // 121
size: fileSize, // 122
unit: 'bytes', // 123
partial: false // 124
}; // 125
}; // 126
// 127
/** // 128
* @method FS.HTTP.Handlers.Get // 129
* @public // 130
* @returns {any} response // 131
* // 132
* HTTP GET request handler // 133
*/ // 134
FS.HTTP.Handlers.Get = function httpGetHandler(ref) { // 135
var self = this; // 136
// Once we have the file, we can test allow/deny validators // 137
// XXX: pass on the "share" query eg. ?share=342hkjh23ggj for shared url access? // 138
FS.Utility.validateAction(ref.collection._validators['download'], ref.file, self.userId /*, self.query.shareId*/); // 139
// 140
var storeName = ref.storeName; // 141
// 142
// If no storeName was specified, use the first defined storeName // 143
if (typeof storeName !== "string") { // 144
// No store handed, we default to primary store // 145
storeName = ref.collection.primaryStore.name; // 146
} // 147
// 148
// Get the storage reference // 149
var storage = ref.collection.storesLookup[storeName]; // 150
// 151
if (!storage) { // 152
throw new Meteor.Error(404, "Not Found", 'There is no store "' + storeName + '"'); // 153
} // 154
// 155
// Get the file // 156
var copyInfo = ref.file.copies[storeName]; // 157
// 158
if (!copyInfo) { // 159
throw new Meteor.Error(404, "Not Found", 'This file was not stored in the ' + storeName + ' store'); // 160
} // 161
// 162
// Set the content type for file // 163
if (typeof copyInfo.type === "string") { // 164
self.setContentType(copyInfo.type); // 165
} else { // 166
self.setContentType('application/octet-stream'); // 167
} // 168
// 169
// Add 'Content-Disposition' header if requested a download/attachment URL // 170
if (typeof ref.download !== "undefined") { // 171
var filename = ref.filename || copyInfo.name; // 172
self.addHeader('Content-Disposition', 'attachment; filename="' + filename + '"'); // 173
} else { // 174
self.addHeader('Content-Disposition', 'inline'); // 175
} // 176
// 177
// Get the contents range from request // 178
var range = requestRange(self.request, copyInfo.size); // 179
// 180
// Some browsers cope better if the content-range header is // 181
// still included even for the full file being returned. // 182
self.addHeader('Content-Range', range.unit + ' ' + range.start + '-' + range.end + '/' + range.size); // 183
// 184
// If a chunk/range was requested instead of the whole file, serve that' // 185
if (range.partial) { // 186
self.setStatusCode(206, 'Partial Content'); // 187
} else { // 188
self.setStatusCode(200, 'OK'); // 189
} // 190
// 191
// Add any other global custom headers and collection-specific custom headers // 192
FS.Utility.each(getHeaders.concat(getHeadersByCollection[ref.collection.name] || []), function(header) { // 193
self.addHeader(header[0], header[1]); // 194
}); // 195
// 196
// Inform clients about length (or chunk length in case of ranges) // 197
self.addHeader('Content-Length', range.length); // 198
// 199
// Last modified header (updatedAt from file info) // 200
self.addHeader('Last-Modified', copyInfo.updatedAt.toUTCString()); // 201
// 202
// Inform clients that we accept ranges for resumable chunked downloads // 203
self.addHeader('Accept-Ranges', range.unit); // 204
// 205
if (FS.debug) console.log('Read file "' + (ref.filename || copyInfo.name) + '" ' + range.unit + ' ' + range.start + '-' + range.end + '/' + range.size);
// 207
var readStream = storage.adapter.createReadStream(ref.file, {start: range.start, end: range.end}); // 208
// 209
readStream.on('error', function(err) { // 210
// Send proper error message on get error // 211
if (err.message && err.statusCode) { // 212
self.Error(new Meteor.Error(err.statusCode, err.message)); // 213
} else { // 214
self.Error(new Meteor.Error(503, 'Service unavailable')); // 215
} // 216
}); // 217
// 218
readStream.pipe(self.createWriteStream()); // 219
}; // 220
const originalHandler = FS.HTTP.Handlers.Get;
FS.HTTP.Handlers.Get = function (ref) {
//console.log(ref.filename);
try {
var userAgent = (this.requestHeaders['user-agent']||'').toLowerCase();
if(userAgent.indexOf('msie') >= 0 || userAgent.indexOf('trident') >= 0 || userAgent.indexOf('chrome') >= 0) {
ref.filename = encodeURIComponent(ref.filename);
} else if(userAgent.indexOf('firefox') >= 0) {
ref.filename = Buffer.from(ref.filename).toString('binary');
} else {
/* safari*/
ref.filename = Buffer.from(ref.filename).toString('binary');
}
} catch (ex){
ref.filename = 'tempfix';
}
return originalHandler.call(this, ref);
};
// 221
/** // 222
* @method FS.HTTP.Handlers.PutInsert // 223
* @public // 224
* @returns {Object} response object with _id property // 225
* // 226
* HTTP PUT file insert request handler // 227
*/ // 228
FS.HTTP.Handlers.PutInsert = function httpPutInsertHandler(ref) { // 229
var self = this; // 230
var opts = FS.Utility.extend({}, self.query || {}, self.params || {}); // 231
// 232
FS.debug && console.log("HTTP PUT (insert) handler"); // 233
// 234
// Create the nice FS.File // 235
var fileObj = new FS.File(); // 236
// 237
// Set its name // 238
fileObj.name(opts.filename || null); // 239
// 240
// Attach the readstream as the file's data // 241
fileObj.attachData(self.createReadStream(), {type: self.requestHeaders['content-type'] || 'application/octet-stream'});
// 243
// Validate with insert allow/deny // 244
FS.Utility.validateAction(ref.collection.files._validators['insert'], fileObj, self.userId); // 245
// 246
// Insert file into collection, triggering readStream storage // 247
ref.collection.insert(fileObj); // 248
// 249
// Send response // 250
self.setStatusCode(200); // 251
// 252
// Return the new file id // 253
return {_id: fileObj._id}; // 254
}; // 255
// 256
/** // 257
* @method FS.HTTP.Handlers.PutUpdate // 258
* @public // 259
* @returns {Object} response object with _id and chunk properties // 260
* // 261
* HTTP PUT file update chunk request handler // 262
*/ // 263
FS.HTTP.Handlers.PutUpdate = function httpPutUpdateHandler(ref) { // 264
var self = this; // 265
var opts = FS.Utility.extend({}, self.query || {}, self.params || {}); // 266
// 267
var chunk = parseInt(opts.chunk, 10); // 268
if (isNaN(chunk)) chunk = 0; // 269
// 270
FS.debug && console.log("HTTP PUT (update) handler received chunk: ", chunk); // 271
// 272
// Validate with insert allow/deny; also mounts and retrieves the file // 273
FS.Utility.validateAction(ref.collection.files._validators['insert'], ref.file, self.userId); // 274
// 275
self.createReadStream().pipe( FS.TempStore.createWriteStream(ref.file, chunk) ); // 276
// 277
// Send response // 278
self.setStatusCode(200); // 279
// 280
return { _id: ref.file._id, chunk: chunk }; // 281
}; // 282
// 283
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}).call(this);
(function () {
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// //
// packages/wekan-cfs-access-point/access-point-server.js //
// //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
var path = Npm.require("path"); // 1
// 2
HTTP.publishFormats({ // 3
fileRecordFormat: function (input) { // 4
// Set the method scope content type to json // 5
this.setContentType('application/json'); // 6
if (FS.Utility.isArray(input)) { // 7
return EJSON.stringify(FS.Utility.map(input, function (obj) { // 8
return FS.Utility.cloneFileRecord(obj); // 9
})); // 10
} else { // 11
return EJSON.stringify(FS.Utility.cloneFileRecord(input)); // 12
} // 13
} // 14
}); // 15
// 16
/** // 17
* @method FS.HTTP.setHeadersForGet // 18
* @public // 19
* @param {Array} headers - List of headers, where each is a two-item array in which item 1 is the header name and item 2 is the header value.
* @param {Array|String} [collections] - Which collections the headers should be added for. Omit this argument to add the header for all collections.
* @returns {undefined} // 22
*/ // 23
FS.HTTP.setHeadersForGet = function setHeadersForGet(headers, collections) { // 24
if (typeof collections === "string") { // 25
collections = [collections]; // 26
} // 27
if (collections) { // 28
FS.Utility.each(collections, function(collectionName) { // 29
getHeadersByCollection[collectionName] = headers || []; // 30
}); // 31
} else { // 32
getHeaders = headers || []; // 33
} // 34
}; // 35
// 36
/** // 37
* @method FS.HTTP.publish // 38
* @public // 39
* @param {FS.Collection} collection // 40
* @param {Function} func - Publish function that returns a cursor. // 41
* @returns {undefined} // 42
* // 43
* Publishes all documents returned by the cursor at a GET URL // 44
* with the format baseUrl/record/collectionName. The publish // 45
* function `this` is similar to normal `Meteor.publish`. // 46
*/ // 47
FS.HTTP.publish = function fsHttpPublish(collection, func) { // 48
var name = baseUrl + '/record/' + collection.name; // 49
// Mount collection listing URL using http-publish package // 50
HTTP.publish({ // 51
name: name, // 52
defaultFormat: 'fileRecordFormat', // 53
collection: collection, // 54
collectionGet: true, // 55
collectionPost: false, // 56
documentGet: true, // 57
documentPut: false, // 58
documentDelete: false // 59
}, func); // 60
// 61
FS.debug && console.log("Registered HTTP method GET URLs:\n\n" + name + '\n' + name + '/:id\n'); // 62
}; // 63
// 64
/** // 65
* @method FS.HTTP.unpublish // 66
* @public // 67
* @param {FS.Collection} collection // 68
* @returns {undefined} // 69
* // 70
* Unpublishes a restpoint created by a call to `FS.HTTP.publish` // 71
*/ // 72
FS.HTTP.unpublish = function fsHttpUnpublish(collection) { // 73
// Mount collection listing URL using http-publish package // 74
HTTP.unpublish(baseUrl + '/record/' + collection.name); // 75
}; // 76
// 77
_existingMountPoints = {}; // 78
// 79
/** // 80
* @method defaultSelectorFunction // 81
* @private // 82
* @returns { collection, file } // 83
* // 84
* This is the default selector function // 85
*/ // 86
var defaultSelectorFunction = function() { // 87
var self = this; // 88
// Selector function // 89
// // 90
// This function will have to return the collection and the // 91
// file. If file not found undefined is returned - if null is returned the // 92
// search was not possible // 93
var opts = FS.Utility.extend({}, self.query || {}, self.params || {}); // 94
// 95
// Get the collection name from the url // 96
var collectionName = opts.collectionName; // 97
// 98
// Get the id from the url // 99
var id = opts.id; // 100
// 101
// Get the collection // 102
var collection = FS._collections[collectionName]; // 103
// 104
// Get the file if possible else return null // 105
var file = (id && collection)? collection.findOne({ _id: id }): null; // 106
// 107
// Return the collection and the file // 108
return { // 109
collection: collection, // 110
file: file, // 111
storeName: opts.store, // 112
download: opts.download, // 113
filename: opts.filename // 114
}; // 115
}; // 116
// 117
/* // 118
* @method FS.HTTP.mount // 119
* @public // 120
* @param {array of string} mountPoints mount points to map rest functinality on // 121
* @param {function} selector_f [selector] function returns `{ collection, file }` for mount points to work with // 122
* // 123
*/ // 124
FS.HTTP.mount = function(mountPoints, selector_f) { // 125
// We take mount points as an array and we get a selector function // 126
var selectorFunction = selector_f || defaultSelectorFunction; // 127
// 128
var accessPoint = { // 129
'stream': true, // 130
'auth': expirationAuth, // 131
'post': function(data) { // 132
// Use the selector for finding the collection and file reference // 133
var ref = selectorFunction.call(this); // 134
// 135
// We dont support post - this would be normal insert eg. of filerecord? // 136
throw new Meteor.Error(501, "Not implemented", "Post is not supported"); // 137
}, // 138
'put': function(data) { // 139
// Use the selector for finding the collection and file reference // 140
var ref = selectorFunction.call(this); // 141
// 142
// Make sure we have a collection reference // 143
if (!ref.collection) // 144
throw new Meteor.Error(404, "Not Found", "No collection found"); // 145
// 146
// Make sure we have a file reference // 147
if (ref.file === null) { // 148
// No id supplied so we will create a new FS.File instance and // 149
// insert the supplied data. // 150
return FS.HTTP.Handlers.PutInsert.apply(this, [ref]); // 151
} else { // 152
if (ref.file) { // 153
return FS.HTTP.Handlers.PutUpdate.apply(this, [ref]); // 154
} else { // 155
throw new Meteor.Error(404, "Not Found", 'No file found'); // 156
} // 157
} // 158
}, // 159
'get': function(data) { // 160
// Use the selector for finding the collection and file reference // 161
var ref = selectorFunction.call(this); // 162
// 163
// Make sure we have a collection reference // 164
if (!ref.collection) // 165
throw new Meteor.Error(404, "Not Found", "No collection found"); // 166
// 167
// Make sure we have a file reference // 168
if (ref.file === null) { // 169
// No id supplied so we will return the published list of files ala // 170
// http.publish in json format // 171
return FS.HTTP.Handlers.GetList.apply(this, [ref]); // 172
} else { // 173
if (ref.file) { // 174
return FS.HTTP.Handlers.Get.apply(this, [ref]); // 175
} else { // 176
throw new Meteor.Error(404, "Not Found", 'No file found'); // 177
} // 178
} // 179
}, // 180
'delete': function(data) { // 181
// Use the selector for finding the collection and file reference // 182
var ref = selectorFunction.call(this); // 183
// 184
// Make sure we have a collection reference // 185
if (!ref.collection) // 186
throw new Meteor.Error(404, "Not Found", "No collection found"); // 187
// 188
// Make sure we have a file reference // 189
if (ref.file) { // 190
return FS.HTTP.Handlers.Del.apply(this, [ref]); // 191
} else { // 192
throw new Meteor.Error(404, "Not Found", 'No file found'); // 193
} // 194
} // 195
}; // 196
// 197
var accessPoints = {}; // 198
// 199
// Add debug message // 200
FS.debug && console.log('Registered HTTP method URLs:'); // 201
// 202
FS.Utility.each(mountPoints, function(mountPoint) { // 203
// Couple mountpoint and accesspoint // 204
accessPoints[mountPoint] = accessPoint; // 205
// Remember our mountpoints // 206
_existingMountPoints[mountPoint] = mountPoint; // 207
// Add debug message // 208
FS.debug && console.log(mountPoint); // 209
}); // 210
// 211
// XXX: HTTP:methods should unmount existing mounts in case of overwriting? // 212
HTTP.methods(accessPoints); // 213
// 214
}; // 215
// 216
/** // 217
* @method FS.HTTP.unmount // 218
* @public // 219
* @param {string | array of string} [mountPoints] Optional, if not specified all mountpoints are unmounted // 220
* // 221
*/ // 222
FS.HTTP.unmount = function(mountPoints) { // 223
// The mountPoints is optional, can be string or array if undefined then // 224
// _existingMountPoints will be used // 225
var unmountList; // 226
// Container for the mount points to unmount // 227
var unmountPoints = {}; // 228
// 229
if (typeof mountPoints === 'undefined') { // 230
// Use existing mount points - unmount all // 231
unmountList = _existingMountPoints; // 232
} else if (mountPoints === ''+mountPoints) { // 233
// Got a string // 234
unmountList = [mountPoints]; // 235
} else if (mountPoints.length) { // 236
// Got an array // 237
unmountList = mountPoints; // 238
} // 239
// 240
// If we have a list to unmount // 241
if (unmountList) { // 242
// Iterate over each item // 243
FS.Utility.each(unmountList, function(mountPoint) { // 244
// Check _existingMountPoints to make sure the mount point exists in our // 245
// context / was created by the FS.HTTP.mount // 246
if (_existingMountPoints[mountPoint]) { // 247
// Mark as unmount // 248
unmountPoints[mountPoint] = false; // 249
// Release // 250
delete _existingMountPoints[mountPoint]; // 251
} // 252
}); // 253
FS.debug && console.log('FS.HTTP.unmount:'); // 254
FS.debug && console.log(unmountPoints); // 255
// Complete unmount // 256
HTTP.methods(unmountPoints); // 257
} // 258
}; // 259
// 260
// ### FS.Collection maps on HTTP pr. default on the following restpoints: // 261
// * // 262
// baseUrl + '/files/:collectionName/:id/:filename', // 263
// baseUrl + '/files/:collectionName/:id', // 264
// baseUrl + '/files/:collectionName' // 265
// // 266
// Change/ replace the existing mount point by: // 267
// ```js // 268
// // unmount all existing // 269
// FS.HTTP.unmount(); // 270
// // Create new mount point // 271
// FS.HTTP.mount([ // 272
// '/cfs/files/:collectionName/:id/:filename', // 273
// '/cfs/files/:collectionName/:id', // 274
// '/cfs/files/:collectionName' // 275
// ]); // 276
// ``` // 277
// // 278
mountUrls = function mountUrls() { // 279
// We unmount first in case we are calling this a second time // 280
FS.HTTP.unmount(); // 281
// 282
FS.HTTP.mount([ // 283
baseUrl + '/files/:collectionName/:id/:filename', // 284
baseUrl + '/files/:collectionName/:id', // 285
baseUrl + '/files/:collectionName' // 286
]); // 287
}; // 288
// 289
// Returns the userId from URL token // 290
var expirationAuth = function expirationAuth() { // 291
var self = this; // 292
// 293
// Read the token from '/hello?token=base64' // 294
var encodedToken = self.query.token; // 295
// 296
FS.debug && console.log("token: "+encodedToken); // 297
// 298
if (!encodedToken || !Meteor.users) return false; // 299
// 300
// Check the userToken before adding it to the db query // 301
// Set the this.userId // 302
var tokenString = FS.Utility.atob(encodedToken); // 303
// 304
var tokenObject; // 305
try { // 306
tokenObject = JSON.parse(tokenString); // 307
} catch(err) { // 308
throw new Meteor.Error(400, 'Bad Request'); // 309
} // 310
// 311
// XXX: Do some check here of the object // 312
var userToken = tokenObject.authToken; // 313
if (userToken !== ''+userToken) { // 314
throw new Meteor.Error(400, 'Bad Request'); // 315
} // 316
// 317
// If we have an expiration token we should check that it's still valid // 318
if (tokenObject.expiration != null) { // 319
// check if its too old // 320
var now = Date.now(); // 321
if (tokenObject.expiration < now) { // 322
FS.debug && console.log('Expired token: ' + tokenObject.expiration + ' is less than ' + now); // 323
throw new Meteor.Error(500, 'Expired token'); // 324
} // 325
} // 326
// 327
// We are not on a secure line - so we have to look up the user... // 328
var user = Meteor.users.findOne({ // 329
$or: [ // 330
{'services.resume.loginTokens.hashedToken': Accounts._hashLoginToken(userToken)}, // 331
{'services.resume.loginTokens.token': userToken} // 332
] // 333
}); // 334
// 335
// Set the userId in the scope // 336
return user && user._id; // 337
}; // 338
// 339
HTTP.methods( // 340
{'/cfs/servertime': { // 341
get: function(data) { // 342
return Date.now().toString(); // 343
} // 344
} // 345
}); // 346
// 347
// Unify client / server api // 348
FS.HTTP.now = function() { // 349
return Date.now(); // 350
}; // 351
// 352
// Start up the basic mount points // 353
Meteor.startup(function () { // 354
mountUrls(); // 355
}); // 356
// 357
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
}).call(this);
///////////////////////////////////////////////////////////////////////
}).call(this);
/* Exports */
if (typeof Package === 'undefined') Package = {};
Package['wekan-cfs-access-point'] = {};
})();

@ -153,11 +153,13 @@ if (Meteor.isServer) {
}
if (activity.listId) {
const list = activity.list();
if (list.watchers !== undefined) {
watchers = _.union(watchers, list.watchers || []);
if (list) {
if (list.watchers !== undefined) {
watchers = _.union(watchers, list.watchers || []);
}
params.list = list.title;
params.listId = activity.listId;
}
params.list = list.title;
params.listId = activity.listId;
}
if (activity.oldListId) {
const oldList = activity.oldList();
@ -242,7 +244,7 @@ if (Meteor.isServer) {
}
if (activity.attachmentId) {
const attachment = activity.attachment();
params.attachment = attachment.original.name;
params.attachment = attachment.name;
params.attachmentId = attachment._id;
}
if (activity.checklistId) {

@ -1,268 +1,96 @@
export const AttachmentStorage = new Mongo.Collection(
'cfs_gridfs.attachments.files',
);
export const AvatarStorage = new Mongo.Collection('cfs_gridfs.avatars.files');
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);
import { Meteor } from 'meteor/meteor';
import { FilesCollection } from 'meteor/ostrio:files';
import fs from 'fs';
import path from 'path';
import { createBucket } from './lib/grid/createBucket';
import { createOnAfterUpload } from './lib/fsHooks/createOnAfterUpload';
import { createInterceptDownload } from './lib/fsHooks/createInterceptDownload';
import { createOnAfterRemove } from './lib/fsHooks/createOnAfterRemove';
let attachmentBucket;
if (Meteor.isServer) {
attachmentBucket = createBucket('attachments');
}
const _FStore = new FS.Store.FileSystem(storeName, {
path: localFSStore,
...defaultStoreOptions,
const insertActivity = (fileObj, activityType) =>
Activities.insert({
userId: fileObj.userId,
type: 'card',
activityType,
attachmentId: fileObj._id,
// this preserves the name so that notifications can be meaningful after
// this file is removed
attachmentName: fileObj.name,
boardId: fileObj.meta.boardId,
cardId: fileObj.meta.cardId,
listId: fileObj.meta.listId,
swimlaneId: fileObj.meta.swimlaneId,
});
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 || {};
// XXX Enforce a schema for the Attachments FilesCollection
// see: https://github.com/VeliovGroup/Meteor-Files/wiki/Schema
// 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;
Attachments = new FilesCollection({
debug: false, // Change to `true` for debugging
collectionName: 'attachments',
allowClientCode: true,
storagePath() {
if (process.env.WRITABLE_PATH) {
return path.join(process.env.WRITABLE_PATH, 'uploads', 'attachments');
}
if (stream) return stream;
else {
try {
const stream = GStore[CRS](GStore.fileKey(fileObj), options);
return stream;
} catch (e) {
return undefined;
}
return path.normalize(`assets/app/uploads/${this.collectionName}`);
},
onAfterUpload: function onAfterUpload(fileRef) {
createOnAfterUpload(attachmentBucket).call(this, fileRef);
// If the attachment doesn't have a source field
// or its source is different than import
if (!fileRef.meta.source || fileRef.meta.source !== 'import') {
// Add activity about adding the attachment
insertActivity(fileRef, 'addAttachment');
}
}.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],
},
interceptDownload: createInterceptDownload(attachmentBucket),
onAfterRemove: function onAfterRemove(files) {
createOnAfterRemove(attachmentBucket).call(this, files);
files.forEach(fileObj => {
insertActivity(fileObj, 'deleteAttachment');
});
},
// 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
protected(fileObj) {
const board = Boards.findOne(fileObj.meta.boardId);
if (board.isPublic()) {
return true;
}
return board.hasMember(this.userId);
},
});
if (Meteor.isServer) {
Meteor.startup(() => {
Attachments.files._ensureIndex({ cardId: 1 });
});
Attachments.allow({
insert(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
insert(userId, fileObj) {
return allowIsBoardMember(userId, Boards.findOne(fileObj.boardId));
},
update(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
update(userId, fileObj) {
return allowIsBoardMember(userId, Boards.findOne(fileObj.boardId));
},
remove(userId, doc) {
return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
remove(userId, fileObj) {
return allowIsBoardMember(userId, Boards.findOne(fileObj.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) {
const board = Boards.findOne(doc.boardId);
if (board.isPublic()) {
return true;
} else {
return board.hasMember(userId);
}
},
fetch: ['boardId'],
fetch: ['meta'],
});
}
// 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: '',
},
},
);
Meteor.startup(() => {
Attachments.collection._ensureIndex({ cardId: 1 });
const storagePath = Attachments.storagePath();
console.log("Meteor.startup check storagePath: ", storagePath);
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
});
Attachments.files.before.remove((userId, doc) => {
Activities.insert({
userId,
type: 'card',
activityType: 'deleteAttachment',
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,
});
});
}
export default Attachments;

@ -0,0 +1,116 @@
const storeName = 'attachments';
const defaultStoreOptions = {
beforeWrite: fileObj => {
if (!fileObj.isImage()) {
return {
type: 'application/octet-stream',
};
}
return {};
},
};
let store;
store = new FS.Store.GridFS(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,
});
AttachmentsOld = new FS.Collection('attachments', {
stores: [store],
});
if (Meteor.isServer) {
Meteor.startup(() => {
AttachmentsOld.files._ensureIndex({ cardId: 1 });
});
AttachmentsOld.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) {
const board = Boards.findOne(doc.boardId);
if (board.isPublic()) {
return true;
} else {
return board.hasMember(userId);
}
},
fetch: ['boardId'],
});
}
// XXX Enforce a schema for the AttachmentsOld CollectionFS
if (Meteor.isServer) {
AttachmentsOld.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
AttachmentsOld.update(
{
_id: doc._id,
},
{
$unset: {
source: '',
},
},
);
}
});
AttachmentsOld.files.before.remove((userId, doc) => {
Activities.insert({
userId,
type: 'card',
activityType: 'deleteAttachment',
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,
});
});
}
export default AttachmentsOld;

@ -1,29 +1,57 @@
Avatars = new FS.Collection('avatars', {
stores: [new FS.Store.GridFS('avatars')],
filter: {
maxSize: 520000,
allow: {
contentTypes: ['image/*'],
},
},
});
import { Meteor } from 'meteor/meteor';
import { FilesCollection } from 'meteor/ostrio:files';
import fs from 'fs';
import path from 'path';
import { createBucket } from './lib/grid/createBucket';
import { createOnAfterUpload } from './lib/fsHooks/createOnAfterUpload';
import { createInterceptDownload } from './lib/fsHooks/createInterceptDownload';
import { createOnAfterRemove } from './lib/fsHooks/createOnAfterRemove';
function isOwner(userId, file) {
return userId && userId === file.userId;
let avatarsBucket;
if (Meteor.isServer) {
avatarsBucket = createBucket('avatars');
}
Avatars.allow({
insert: isOwner,
update: isOwner,
remove: isOwner,
download() {
return true;
Avatars = new FilesCollection({
debug: false, // Change to `true` for debugging
collectionName: 'avatars',
allowClientCode: true,
storagePath() {
if (process.env.WRITABLE_PATH) {
return path.join(process.env.WRITABLE_PATH, 'uploads', 'avatars');
}
return path.normalize(`assets/app/uploads/${this.collectionName}`);;
},
fetch: ['userId'],
onBeforeUpload(file) {
if (file.size <= 72000 && file.type.startsWith('image/')) {
return true;
}
return 'avatar-too-big';
},
onAfterUpload: createOnAfterUpload(avatarsBucket),
interceptDownload: createInterceptDownload(avatarsBucket),
onAfterRemove: createOnAfterRemove(avatarsBucket),
});
Avatars.files.before.insert((userId, doc) => {
doc.userId = userId;
});
function isOwner(userId, doc) {
return userId && userId === doc.userId;
}
if (Meteor.isServer) {
Avatars.allow({
insert: isOwner,
update: isOwner,
remove: isOwner,
fetch: ['userId'],
});
Meteor.startup(() => {
const storagePath = Avatars.storagePath();
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
});
}
export default Avatars;

@ -0,0 +1,29 @@
AvatarsOld = new FS.Collection('avatars', {
stores: [new FS.Store.GridFS('avatars')],
filter: {
maxSize: 72000,
allow: {
contentTypes: ['image/*'],
},
},
});
function isOwner(userId, file) {
return userId && userId === file.userId;
}
AvatarsOld.allow({
insert: isOwner,
update: isOwner,
remove: isOwner,
download() {
return true;
},
fetch: ['userId'],
});
AvatarsOld.files.before.insert((userId, doc) => {
doc.userId = userId;
});
export default AvatarsOld;

@ -8,6 +8,7 @@ import {
import Users from "./users";
const escapeForRegex = require('escape-string-regexp');
Boards = new Mongo.Collection('boards');
/**

@ -737,14 +737,14 @@ Cards.helpers({
attachments() {
if (this.isLinkedCard()) {
return Attachments.find(
{ cardId: this.linkedId },
{ 'meta.cardId': this.linkedId },
{ sort: { uploadedAt: -1 } },
);
).each();
} else {
return Attachments.find(
{ cardId: this._id },
{ 'meta.cardId': this._id },
{ sort: { uploadedAt: -1 } },
);
).each();
}
},
@ -753,7 +753,7 @@ Cards.helpers({
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
// todo XXX we could return a default "upload pending" image in the meantime?
return cover && cover.url() && cover;
return cover && cover.link() && cover;
},
checklists() {

@ -1,5 +1,7 @@
const Papa = require('papaparse');
//const stringify = require('csv-stringify');
// exporter maybe is broken since Gridfs introduced, add fs and path
export class Exporter {
constructor(boardId, attachmentId) {
@ -78,11 +80,11 @@ export class Exporter {
return {
_id: attachment._id,
cardId: attachment.cardId,
cardId: attachment.meta.cardId,
//url: FlowRouter.url(attachment.url()),
file: filebase64,
name: attachment.original.name,
type: attachment.original.type,
name: attachment.name,
type: attachment.type,
};
});
//When has a especific valid attachment return the single element
@ -209,7 +211,7 @@ export class Exporter {
delimiter: userDelimiter,
header: true,
newline: "\r\n",
skipEmptyLines: false,
skipEmptyLines: false,
escapeFormulae: true,
};

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

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

@ -0,0 +1,51 @@
import { Meteor } from 'meteor/meteor';
import fs from 'fs';
export const createOnAfterUpload = bucket =>
function onAfterUpload(file) {
const self = this;
// here you could manipulate your file
// and create a new version, for example a scaled 'thumbnail'
// ...
// then we read all versions we have got so far
Object.keys(file.versions).forEach(versionName => {
const metadata = { ...file.meta, versionName, fileId: file._id };
fs.createReadStream(file.versions[versionName].path)
// this is where we upload the binary to the bucket using bucket.openUploadStream
// see http://mongodb.github.io/node-mongodb-native/3.2/api/GridFSBucket.html#openUploadStream
.pipe(
bucket.openUploadStream(file.name, {
contentType: file.type || 'binary/octet-stream',
metadata,
}),
)
// and we unlink the file from the fs on any error
// that occurred during the upload to prevent zombie files
.on('error', err => {
console.error("[createOnAfterUpload error]", err);
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
})
// once we are finished, we attach the gridFS Object id on the
// FilesCollection document's meta section and finally unlink the
// upload file from the filesystem
.on(
'finish',
Meteor.bindEnvironment(ver => {
const property = `versions.${versionName}.meta.gridFsFileId`;
self.collection.update(file._id, {
$set: {
[property]: ver._id.toHexString(),
},
});
self.unlink(this.collection.findOne(file._id), versionName); // Unlink files from FS
}),
);
});
};

@ -0,0 +1,9 @@
import { MongoInternals } from 'meteor/mongo';
export const createBucket = bucketName => {
const options = bucketName ? { bucketName } : void 0;
return new MongoInternals.NpmModule.GridFSBucket(
MongoInternals.defaultRemoteCollectionDriver().mongo.db,
options,
);
};

@ -0,0 +1,4 @@
import { MongoInternals } from 'meteor/mongo';
export const createObjectId = ({ gridFsFileId }) =>
new MongoInternals.NpmModule.ObjectID(gridFsFileId);

@ -422,46 +422,34 @@ export class TrelloCreator {
}
const attachments = this.attachments[card.id];
const trelloCoverId = card.idAttachmentCover;
if (attachments) {
const links = [];
if (attachments && Meteor.isServer) {
attachments.forEach(att => {
// if the attachment `name` and `url` are the same, then the
// attachment is an attached link
if (att.name === att.url) {
links.push(att.url);
} else {
const file = new FS.File();
// Simulating file.attachData on the client generates multiple errors
// - HEAD returns null, which causes exception down the line
// - the template then tries to display the url to the attachment which causes other errors
// so we make it server only, and let UI catch up once it is done, forget about latency comp.
const self = this;
if (Meteor.isServer) {
file.attachData(att.url, function(error) {
file.boardId = boardId;
file.cardId = cardId;
file.userId = self._user(att.idMemberCreator);
// The field source will only be used to prevent adding
// attachments' related activities automatically
file.source = 'import';
if (error) {
throw error;
} else {
const wekanAtt = Attachments.insert(file, () => {
// we do nothing
});
self.attachmentIds[att.id] = wekanAtt._id;
//
if (trelloCoverId === att.id) {
Cards.direct.update(cardId, {
$set: { coverId: wekanAtt._id },
});
}
}
const self = this;
const opts = {
type: att.type ? att.type : undefined,
userId: self._user(att.userId),
meta: {
boardId,
cardId,
source: 'import',
},
};
const cb = (error, fileObj) => {
if (error) {
throw error;
}
self.attachmentIds[att._id] = fileObj._id;
if (trelloCoverId === att._id) {
Cards.direct.update(cardId, {
$set: { coverId: fileObj._id },
});
}
};
if (att.url) {
Attachment.load(att.url, opts, cb, true);
} else if (att.file) {
Attachment.write(att.file, opts, cb, true);
}
// todo XXX set cover - if need be
});
if (links.length) {

@ -444,81 +444,34 @@ export class WekanCreator {
}
const attachments = this.attachments[card._id];
const wekanCoverId = card.coverId;
if (attachments) {
if (attachments && Meteor.isServer) {
attachments.forEach(att => {
const file = new FS.File();
// Simulating file.attachData on the client generates multiple errors
// - HEAD returns null, which causes exception down the line
// - the template then tries to display the url to the attachment which causes other errors
// so we make it server only, and let UI catch up once it is done, forget about latency comp.
const self = this;
if (Meteor.isServer) {
if (att.url) {
file.attachData(att.url, function(error) {
file.boardId = boardId;
file.cardId = cardId;
file.userId = self._user(att.userId);
// The field source will only be used to prevent adding
// attachments' related activities automatically
file.source = 'import';
if (error) {
throw error;
} else {
const wekanAtt = Attachments.insert(file, () => {
// we do nothing
});
self.attachmentIds[att._id] = wekanAtt._id;
//
if (wekanCoverId === att._id) {
Cards.direct.update(cardId, {
$set: {
coverId: wekanAtt._id,
},
});
}
}
const opts = {
type: att.type ? att.type : undefined,
userId: self._user(att.userId),
meta: {
boardId,
cardId,
source: 'import',
},
};
const cb = (error, fileObj) => {
if (error) {
throw error;
}
self.attachmentIds[att._id] = fileObj._id;
if (wekanCoverId === att._id) {
Cards.direct.update(cardId, {
$set: { coverId: fileObj._id },
});
} else if (att.file) {
//If attribute type is null or empty string is set, assume binary stream
att.type =
!att.type || att.type.trim().length === 0
? 'application/octet-stream'
: att.type;
file.attachData(
Buffer.from(att.file, 'base64'),
{
type: att.type,
},
error => {
file.name(att.name);
file.boardId = boardId;
file.cardId = cardId;
file.userId = self._user(att.userId);
// The field source will only be used to prevent adding
// attachments' related activities automatically
file.source = 'import';
if (error) {
throw error;
} else {
const wekanAtt = Attachments.insert(file, () => {
// we do nothing
});
this.attachmentIds[att._id] = wekanAtt._id;
//
if (wekanCoverId === att._id) {
Cards.direct.update(cardId, {
$set: {
coverId: wekanAtt._id,
},
});
}
}
},
);
}
};
if (att.url) {
Attachment.load(att.url, opts, cb, true);
} else if (att.file) {
Attachment.write(att.file, opts, cb, true);
}
// todo XXX set cover - if need be
});
}
result.push(cardId);

@ -52,7 +52,6 @@ REM del /S /F /Q node_modules
call meteor npm install
REM del /S /F /Q .build
call meteor build .build --directory
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.
del /S /F /Q rm .build/bundle/programs/web.browser.legacy
REM ## Install some NPM packages

@ -10,7 +10,6 @@ rm -rf node_modules
meteor npm install
rm -rf .build
METEOR_PROFILE=100 meteor build .build --directory
cp -f fix-download-unicode/cfs_access-point.txt .build/bundle/programs/server/packages/cfs_access-point.js
# Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
rm -rf .build/bundle/programs/web.browser.legacy
cd .build/bundle/programs/server

@ -1,8 +1,14 @@
import fs from 'fs';
import path from 'path';
import AccountSettings from '../models/accountSettings';
import TableVisibilityModeSettings from '../models/tableVisibilityModeSettings';
import Actions from '../models/actions';
import Activities from '../models/activities';
import Announcements from '../models/announcements';
import Attachments from '../models/attachments';
import AttachmentsOld from '../models/attachments_old';
import Avatars from '../models/avatars';
import AvatarsOld from '../models/avatars_old';
import Boards from '../models/boards';
import CardComments from '../models/cardComments';
import Cards from '../models/cards';
@ -1119,3 +1125,152 @@ Migrations.add('add-card-details-show-lists', () => {
noValidateMulti,
);
});
Migrations.add('migrate-attachments-collectionFS-to-ostrioFiles', () => {
const storagePath = Attachments.storagePath();
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
AttachmentsOld.find().forEach(function(fileObj) {
const newFileName = fileObj.name();
const filePath = path.join(storagePath, `${fileObj._id}-${newFileName}`);
// This is "example" variable, change it to the userId that you might be using.
const userId = fileObj.userId;
const fileType = fileObj.type();
const fileSize = fileObj.size();
const fileId = fileObj._id;
const readStream = fileObj.createReadStream('attachments');
const writeStream = fs.createWriteStream(filePath);
writeStream.on('error', error => {
console.error('[writeStream error]: ', error, filePath);
});
readStream.on('error', error => {
console.error('[readStream error]: ', error, filePath);
});
// Once we have a file, then upload it to our new data storage
readStream.on('end', () => {
console.log('Ended: ', filePath);
// UserFiles is the new Meteor-Files/FilesCollection collection instance
Attachments.addFile(
filePath,
{
fileName: newFileName,
type: fileType,
meta: {
boardId: fileObj.boardId,
cardId: fileObj.cardId,
listId: fileObj.listId,
swimlaneId: fileObj.swimlaneId,
source: 'import,'
},
userId,
size: fileSize,
fileId,
},
(error, fileRef) => {
if (error) {
console.error('[Attachments#addFile error]: ', error);
} else {
console.log('File Inserted: ', fileRef);
// Set the userId again
Attachments.update({ _id: fileRef._id }, { $set: { userId } });
fileObj.remove();
}
},
true,
); // proceedAfterUpload
});
readStream.pipe(writeStream);
});
});
Migrations.add('migrate-avatars-collectionFS-to-ostrioFiles', () => {
const storagePath = Avatars.storagePath();
if (!fs.existsSync(storagePath)) {
console.log("create storagePath because it doesn't exist: " + storagePath);
fs.mkdirSync(storagePath, { recursive: true });
}
AvatarsOld.find().forEach(function(fileObj) {
const newFileName = fileObj.name();
const filePath = path.join(storagePath, `${fileObj._id}-${newFileName}`);
// This is "example" variable, change it to the userId that you might be using.
const userId = fileObj.userId;
const fileType = fileObj.type();
const fileSize = fileObj.size();
const fileId = fileObj._id;
const readStream = fileObj.createReadStream('avatars');
const writeStream = fs.createWriteStream(filePath);
writeStream.on('error', error => {
console.error('[writeStream error]: ', error, filePath);
});
readStream.on('error', error => {
console.error('[readStream error]: ', error, filePath);
});
// Once we have a file, then upload it to our new data storage
readStream.on('end', () => {
console.log('Ended: ', filePath);
// UserFiles is the new Meteor-Files/FilesCollection collection instance
Avatars.addFile(
filePath,
{
fileName: newFileName,
type: fileType,
meta: {
boardId: fileObj.boardId,
cardId: fileObj.cardId,
listId: fileObj.listId,
swimlaneId: fileObj.swimlaneId,
},
userId,
size: fileSize,
fileId,
},
(error, fileRef) => {
if (error) {
console.error('[Avatars#addFile error]: ', error);
} else {
console.log('File Inserted: ', newFileName, fileRef);
// Set the userId again
Avatars.update({ _id: fileRef._id }, { $set: { userId } });
Users.find().forEach(user => {
const old_url = fileObj.url();
new_url = Avatars.findOne({ _id: fileRef._id }).link(
'original',
'/',
);
if (user.profile.avatarUrl.startsWith(old_url)) {
// Set avatar url to new url
Users.direct.update(
{ _id: user._id },
{ $set: { 'profile.avatarUrl': new_url } },
noValidate,
);
console.log('User avatar updated: ', user._id, new_url);
}
});
fileObj.remove();
}
},
true, // proceedAfterUpload
);
});
readStream.pipe(writeStream);
});
});

@ -1,3 +1,4 @@
import Avatars from '../../models/avatars';
Meteor.publish('my-avatars', function() {
return Avatars.find({ userId: this.userId });
return Avatars.find({ userId: this.userId }).cursor;
});

@ -233,8 +233,8 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
cardCommentsLinkedBoard.selector = _ids => ({ boardId: _ids });
const cardCommentReactions = this.join(CardCommentReactions);
cardCommentReactions.selector = _ids => ({ cardId: _ids });
const attachments = this.join(Attachments);
attachments.selector = _ids => ({ cardId: _ids });
const attachments = this.join(Attachments.collection);
attachments.selector = _ids => ({ 'meta.cardId': _ids });
const checklists = this.join(Checklists);
checklists.selector = _ids => ({ cardId: _ids });
const checklistItems = this.join(ChecklistItems);

@ -12,7 +12,7 @@ Meteor.publish('notificationAttachments', function() {
$in: activities()
.map(v => v.attachmentId)
.filter(v => !!v),
},
}.cursor,
});
});

@ -3,7 +3,7 @@
# All supported keys are defined here together with descriptions and default values
# list of supported keys
keys="DEBUG MONGO_LOG_DESTINATION MONGO_URL MONGODB_BIND_UNIX_SOCKET MONGO_URL MONGODB_BIND_IP MONGODB_PORT MAIL_URL MAIL_FROM MAIL_SERVICE MAIL_SERVICE_USER MAIL_SERVICE_PASSWORD ROOT_URL PORT DISABLE_MONGODB CADDY_ENABLED CADDY_BIND_PORT WITH_API RICHER_CARD_COMMENT_EDITOR CARD_OPENED_WEBHOOK_ENABLED ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS MAX_IMAGE_PIXEL IMAGE_COMPRESS_RATIO BIGEVENTS_PATTERN NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE NOTIFY_DUE_DAYS_BEFORE_AND_AFTER NOTIFY_DUE_AT_HOUR_OF_DAY EMAIL_NOTIFICATION_TIMEOUT CORS CORS_ALLOW_HEADERS CORS_EXPOSE_HEADERS MATOMO_ADDRESS MATOMO_SITE_ID MATOMO_DO_NOT_TRACK MATOMO_WITH_USERNAME BROWSER_POLICY_ENABLED TRUSTED_URL WEBHOOKS_ATTRIBUTES OAUTH2_ENABLED OAUTH2_CA_CERT OAUTH2_LOGIN_STYLE OAUTH2_CLIENT_ID OAUTH2_SECRET OAUTH2_SERVER_URL OAUTH2_AUTH_ENDPOINT OAUTH2_USERINFO_ENDPOINT OAUTH2_TOKEN_ENDPOINT OAUTH2_ID_MAP OAUTH2_USERNAME_MAP OAUTH2_FULLNAME_MAP OAUTH2_ID_TOKEN_WHITELIST_FIELDS OAUTH2_EMAIL_MAP OAUTH2_REQUEST_PERMISSIONS OAUTH2_ADFS_ENABLED LDAP_ENABLE LDAP_PORT LDAP_HOST LDAP_AD_SIMPLE_AUTH LDAP_BASEDN LDAP_LOGIN_FALLBACK LDAP_RECONNECT LDAP_TIMEOUT LDAP_IDLE_TIMEOUT LDAP_CONNECT_TIMEOUT LDAP_AUTHENTIFICATION LDAP_AUTHENTIFICATION_USERDN LDAP_AUTHENTIFICATION_PASSWORD LDAP_LOG_ENABLED LDAP_BACKGROUND_SYNC LDAP_BACKGROUND_SYNC_INTERVAL LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS LDAP_ENCRYPTION LDAP_CA_CERT LDAP_REJECT_UNAUTHORIZED LDAP_USER_AUTHENTICATION LDAP_USER_AUTHENTICATION_FIELD LDAP_USER_SEARCH_FILTER LDAP_USER_SEARCH_SCOPE LDAP_USER_SEARCH_FIELD LDAP_SEARCH_PAGE_SIZE LDAP_SEARCH_SIZE_LIMIT LDAP_GROUP_FILTER_ENABLE LDAP_GROUP_FILTER_OBJECTCLASS LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT LDAP_GROUP_FILTER_GROUP_NAME LDAP_UNIQUE_IDENTIFIER_FIELD LDAP_UTF8_NAMES_SLUGIFY LDAP_USERNAME_FIELD LDAP_FULLNAME_FIELD LDAP_MERGE_EXISTING_USERS LDAP_SYNC_USER_DATA LDAP_SYNC_USER_DATA_FIELDMAP LDAP_SYNC_GROUP_ROLES LDAP_DEFAULT_DOMAIN LDAP_EMAIL_MATCH_ENABLE LDAP_EMAIL_MATCH_REQUIRE LDAP_EMAIL_MATCH_VERIFIED LDAP_EMAIL_FIELD LDAP_SYNC_ADMIN_STATUS LDAP_SYNC_ADMIN_GROUPS HEADER_LOGIN_ID HEADER_LOGIN_FIRSTNAME HEADER_LOGIN_LASTNAME HEADER_LOGIN_EMAIL LOGOUT_WITH_TIMER LOGOUT_IN LOGOUT_ON_HOURS LOGOUT_ON_MINUTES DEFAULT_AUTHENTICATION_METHOD ATTACHMENTS_STORE_PATH PASSWORD_LOGIN_ENABLED CAS_ENABLED CAS_BASE_URL CAS_LOGIN_URL CAS_VALIDATE_URL SAML_ENABLED SAML_PROVIDER SAML_ENTRYPOINT SAML_ISSUER SAML_CERT SAML_IDPSLO_REDIRECTURL SAML_PRIVATE_KEYFILE SAML_PUBLIC_CERTFILE SAML_IDENTIFIER_FORMAT SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE SAML_ATTRIBUTES ORACLE_OIM_ENABLED RESULTS_PER_PAGE WAIT_SPINNER NODE_OPTIONS"
keys="DEBUG MONGO_LOG_DESTINATION MONGO_URL MONGODB_BIND_UNIX_SOCKET MONGO_URL MONGODB_BIND_IP MONGODB_PORT MAIL_URL MAIL_FROM MAIL_SERVICE MAIL_SERVICE_USER MAIL_SERVICE_PASSWORD ROOT_URL PORT DISABLE_MONGODB CADDY_ENABLED CADDY_BIND_PORT WITH_API RICHER_CARD_COMMENT_EDITOR CARD_OPENED_WEBHOOK_ENABLED ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS MAX_IMAGE_PIXEL IMAGE_COMPRESS_RATIO BIGEVENTS_PATTERN NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE NOTIFY_DUE_DAYS_BEFORE_AND_AFTER NOTIFY_DUE_AT_HOUR_OF_DAY EMAIL_NOTIFICATION_TIMEOUT CORS CORS_ALLOW_HEADERS CORS_EXPOSE_HEADERS MATOMO_ADDRESS MATOMO_SITE_ID MATOMO_DO_NOT_TRACK MATOMO_WITH_USERNAME BROWSER_POLICY_ENABLED TRUSTED_URL WEBHOOKS_ATTRIBUTES OAUTH2_ENABLED OAUTH2_CA_CERT OAUTH2_LOGIN_STYLE OAUTH2_CLIENT_ID OAUTH2_SECRET OAUTH2_SERVER_URL OAUTH2_AUTH_ENDPOINT OAUTH2_USERINFO_ENDPOINT OAUTH2_TOKEN_ENDPOINT OAUTH2_ID_MAP OAUTH2_USERNAME_MAP OAUTH2_FULLNAME_MAP OAUTH2_ID_TOKEN_WHITELIST_FIELDS OAUTH2_EMAIL_MAP OAUTH2_REQUEST_PERMISSIONS OAUTH2_ADFS_ENABLED LDAP_ENABLE LDAP_PORT LDAP_HOST LDAP_AD_SIMPLE_AUTH LDAP_BASEDN LDAP_LOGIN_FALLBACK LDAP_RECONNECT LDAP_TIMEOUT LDAP_IDLE_TIMEOUT LDAP_CONNECT_TIMEOUT LDAP_AUTHENTIFICATION LDAP_AUTHENTIFICATION_USERDN LDAP_AUTHENTIFICATION_PASSWORD LDAP_LOG_ENABLED LDAP_BACKGROUND_SYNC LDAP_BACKGROUND_SYNC_INTERVAL LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS LDAP_ENCRYPTION LDAP_CA_CERT LDAP_REJECT_UNAUTHORIZED LDAP_USER_AUTHENTICATION LDAP_USER_AUTHENTICATION_FIELD LDAP_USER_SEARCH_FILTER LDAP_USER_SEARCH_SCOPE LDAP_USER_SEARCH_FIELD LDAP_SEARCH_PAGE_SIZE LDAP_SEARCH_SIZE_LIMIT LDAP_GROUP_FILTER_ENABLE LDAP_GROUP_FILTER_OBJECTCLASS LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT LDAP_GROUP_FILTER_GROUP_NAME LDAP_UNIQUE_IDENTIFIER_FIELD LDAP_UTF8_NAMES_SLUGIFY LDAP_USERNAME_FIELD LDAP_FULLNAME_FIELD LDAP_MERGE_EXISTING_USERS LDAP_SYNC_USER_DATA LDAP_SYNC_USER_DATA_FIELDMAP LDAP_SYNC_GROUP_ROLES LDAP_DEFAULT_DOMAIN LDAP_EMAIL_MATCH_ENABLE LDAP_EMAIL_MATCH_REQUIRE LDAP_EMAIL_MATCH_VERIFIED LDAP_EMAIL_FIELD LDAP_SYNC_ADMIN_STATUS LDAP_SYNC_ADMIN_GROUPS HEADER_LOGIN_ID HEADER_LOGIN_FIRSTNAME HEADER_LOGIN_LASTNAME HEADER_LOGIN_EMAIL LOGOUT_WITH_TIMER LOGOUT_IN LOGOUT_ON_HOURS LOGOUT_ON_MINUTES DEFAULT_AUTHENTICATION_METHOD PASSWORD_LOGIN_ENABLED CAS_ENABLED CAS_BASE_URL CAS_LOGIN_URL CAS_VALIDATE_URL SAML_ENABLED SAML_PROVIDER SAML_ENTRYPOINT SAML_ISSUER SAML_CERT SAML_IDPSLO_REDIRECTURL SAML_PRIVATE_KEYFILE SAML_PUBLIC_CERTFILE SAML_IDENTIFIER_FORMAT SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE SAML_ATTRIBUTES ORACLE_OIM_ENABLED RESULTS_PER_PAGE WAIT_SPINNER NODE_OPTIONS"
#---------------------------------------------------------------------
@ -126,10 +126,6 @@ DESCRIPTION_ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS="Accounts common login expi
DEFAULT_ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS="90"
KEY_ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS="accounts-common-login-expiration-in-days"
DESCRIPTION_ATTACHMENTS_STORE_PATH="Allow wekan ower to specify where uploaded files to store on the server instead of the mongodb"
DEFAULT_ATTACHMENTS_STORE_PATH=""
KEY_ATTACHMENTS_STORE_PATH="attachments-store-path"
# Example, not in use: /var/snap/wekan/common/uploads/
DESCRIPTION_MAX_IMAGE_PIXEL="Max image pixel: Allow to shrink attached/pasted image https://github.com/wekan/wekan/pull/2544"

@ -131,12 +131,6 @@ echo -e "\t$ snap set $SNAP_NAME image-compress-ratio='80'"
echo -e "Disable:"
echo -e "\t$ snap unset $SNAP_NAME image-compress-ratio"
echo -e "\n"
echo -e "Allow to set attachment upload into specified server location. Create that directory first. https://github.com/wekan/wekan/pull/2603"
echo -e "Example:"
echo -e "\t$ snap set $SNAP_NAME attachments-store-path='/var/snap/wekan/common/attachments'"
echo -e "Disable:"
echo -e "\t$ snap unset $SNAP_NAME attachments-store-path"
echo -e "\n"
echo -e "NOTIFICATION TRAY AFTER READ DAYS BEFORE REMOVE https://github.com/wekan/wekan/pull/2998"
echo -e "Number of days after a notification is read before we remove it. Default: 2."
echo -e "Example:"

@ -72,8 +72,6 @@ meteor=/home/wekan/.meteor/meteor
#sudo -u wekan ${meteor} add standard-minifier-js
sudo -u wekan ${meteor} npm install
sudo -u wekan ${meteor} build --directory /home/wekan/app_build
sudo cp /home/wekan/app/fix-download-unicode/cfs_access-point.txt /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js
sudo chown wekan:wekan /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js
sudo rm /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/rajit_bootstrap3-datepicker/lib/bootstrap-datepicker/node_modules/phantomjs-prebuilt/lib/phantom/bin/phantomjs
# Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
rm -rf /home/wekan/app_build/bundle/programs/web.browser.legacy

@ -252,10 +252,6 @@ services:
# Defaults below. Uncomment to change. wekan/server/accounts-common.js
# - ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90
#---------------------------------------------------------------
# ==== STORE ATTACHMENT ON SERVER FILESYSTEM INSTEAD OF MONGODB ====
# https://github.com/wekan/wekan/pull/2603
#- ATTACHMENTS_STORE_PATH = <pathname> # pathname can be relative or fullpath
#---------------------------------------------------------------
# ==== RICH TEXT EDITOR IN CARD COMMENTS ====
# https://github.com/wekan/wekan/pull/2560
- RICHER_CARD_COMMENT_EDITOR=false

Loading…
Cancel
Save