diff options
-rw-r--r-- | atachments/attachments-gAjLYeSrtAneFBdzt-提議者電子郵件(第一波+第二波).xlsx | bin | 0 -> 29409 bytes | |||
-rw-r--r-- | client/components/activities/activities.js | 5 | ||||
-rw-r--r-- | client/components/cards/attachments.jade | 13 | ||||
-rw-r--r-- | client/components/cards/attachments.js | 100 | ||||
-rw-r--r-- | client/components/cards/attachments.styl | 11 | ||||
-rw-r--r-- | client/components/cards/minicard.jade | 2 | ||||
-rw-r--r-- | client/components/cards/minicard.js | 3 | ||||
-rwxr-xr-x | client/components/main/editor.js | 50 | ||||
-rw-r--r-- | client/lib/popup.js | 2 | ||||
-rw-r--r-- | client/lib/utils.js | 42 | ||||
-rw-r--r-- | models/activities.js | 6 | ||||
-rw-r--r-- | models/attachments.js | 322 | ||||
-rw-r--r-- | models/cards.js | 11 | ||||
-rw-r--r-- | models/export.js | 2 | ||||
-rw-r--r-- | models/trelloCreator.js | 1 | ||||
-rw-r--r-- | models/wekanCreator.js | 2 | ||||
-rw-r--r-- | package-lock.json | 8 | ||||
-rw-r--r-- | rebuild-wekan.bat | 128 | ||||
-rw-r--r-- | sandstorm-pkgdef.capnp | 3 | ||||
-rw-r--r-- | server/migrations.js | 43 | ||||
-rw-r--r-- | server/old-attachments-migration.js | 212 | ||||
-rw-r--r-- | server/publications/boards.js | 2 |
22 files changed, 583 insertions, 385 deletions
diff --git a/atachments/attachments-gAjLYeSrtAneFBdzt-提議者電子郵件(第一波+第二波).xlsx b/atachments/attachments-gAjLYeSrtAneFBdzt-提議者電子郵件(第一波+第二波).xlsx Binary files differnew file mode 100644 index 00000000..4e89ac5d --- /dev/null +++ b/atachments/attachments-gAjLYeSrtAneFBdzt-提議者電子郵件(第一波+第二波).xlsx diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index 5d356f6e..72af4c35 100644 --- a/client/components/activities/activities.js +++ b/client/components/activities/activities.js @@ -152,14 +152,15 @@ BlazeComponent.extendComponent({ attachmentLink() { const attachment = this.currentData().activity.attachment(); + const link = attachment.link('original', '/'); // trying to display url before file is stored generates js errors return ( (attachment && - attachment.url({ download: true }) && + link && Blaze.toHTML( HTML.A( { - href: attachment.url({ download: true }), + href: link, target: '_blank', }, attachment.name(), diff --git a/client/components/cards/attachments.jade b/client/components/cards/attachments.jade index 61454fa7..eda6d118 100644 --- a/client/components/cards/attachments.jade +++ b/client/components/cards/attachments.jade @@ -18,12 +18,19 @@ template(name="attachmentDeletePopup") p {{_ "attachment-delete-pop"}} button.js-confirm.negate.full(type="submit") {{_ 'delete'}} +template(name="uploadingPopup") + .uploading-info + span.upload-percentage {{progress}}% + .upload-progress-frame + .upload-progress-bar(style="width: {{progress}}%;") + span.upload-size {{fileSize}} + template(name="attachmentsGalery") .attachments-galery each attachments .attachment-item - a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}") - if isUploaded + a.attachment-thumbnail.swipebox(href="{{url}}" download="{{name}}" title="{{name}}") + if isUploaded if isImage img.attachment-thumbnail-img(src="{{url}}") else @@ -33,7 +40,7 @@ template(name="attachmentsGalery") p.attachment-details = name span.attachment-details-actions - a.js-download(href="{{url download=true}}") + a.js-download(href="{{url download=true}}" download="{{name}}") i.fa.fa-download | {{_ 'download'}} if currentUser.isBoardMember diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index e4439155..82ecabcf 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -13,10 +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(); + Cards.findOne(this.meta.cardId).unsetCover(); }, 'click .js-preview-image'(event) { Popup.open('previewAttachedImage').call(this, event); @@ -45,22 +45,60 @@ Template.attachmentsGalery.events({ }, }); +Template.attachmentsGalery.helpers({ + url() { + return Attachments.link(this, 'original', '/'); + }, + isUploaded() { + return !!this.meta.uploaded; + }, +}); + Template.previewAttachedImagePopup.events({ 'click .js-large-image-clicked'() { Popup.close(); }, }); +Template.previewAttachedImagePopup.helpers({ + url() { + return Attachments.link(this, 'original', '/'); + } +}); + +// For uploading popup + +let uploadFileSize = new ReactiveVar(''); +let uploadProgress = new ReactiveVar(0); + Template.cardAttachmentsPopup.events({ - 'change .js-attach-file'(event) { + 'change .js-attach-file'(event, instance) { const card = this; - const processFile = f => { - Utils.processUploadedAttachment(card, f, attachment => { - if (attachment && attachment._id && attachment.isImage()) { - card.setCover(attachment._id); + const callbacks = { + onBeforeUpload: (err, fileData) => { + Popup.open('uploading')(this.clickEvent); + uploadFileSize.set('...'); + uploadProgress.set(0); + return true; + }, + onUploaded: (err, attachment) => { + if (attachment && attachment._id && attachment.isImage) { + card.setCover(attachment._id); + } + Popup.close(); + }, + onStart: (error, fileData) => { + uploadFileSize.set(formatBytes(fileData.size)); + }, + onError: (err, fileObj) => { + console.log('Error!', err); + }, + onProgress: (progress, fileData) => { + uploadProgress.set(progress); } - Popup.close(); - }); + }; + const processFile = f => { + Utils.processUploadedAttachment(card, f, callbacks); }; FS.Utility.eachFile(event, f => { @@ -100,12 +138,22 @@ Template.cardAttachmentsPopup.events({ }); }, 'click .js-computer-upload'(event, templateInstance) { + this.clickEvent = event; templateInstance.find('.js-attach-file').click(); event.preventDefault(); }, 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'), }); +Template.uploadingPopup.helpers({ + fileSize: () => { + return uploadFileSize.get(); + }, + progress: () => { + return uploadProgress.get(); + } +}); + const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL; const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO; let pastedResults = null; @@ -149,20 +197,26 @@ Template.previewClipboardImagePopup.events({ if (results && results.file) { window.oPasted = pastedResults; const card = this; - const file = new FS.File(results.file); + const settings = { + file: results.file, + streams: 'dynamic', + chunkSize: 'dynamic', + }; 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.')); + settings.fileName = + new Date().getTime() + results.file.type.replace('.+/', ''); } } - file.updatedAt(new Date()); - file.boardId = card.boardId; - file.cardId = card._id; - file.userId = Meteor.userId(); - const attachment = Attachments.insert(file); + settings.meta = {}; + settings.meta.updatedAt = new Date().getTime(); + settings.meta.boardId = card.boardId; + settings.meta.cardId = card._id; + settings.meta.userId = Meteor.userId(); + const attachment = Attachments.insert(settings); - if (attachment && attachment._id && attachment.isImage()) { + if (attachment && attachment._id && attachment.isImage) { card.setCover(attachment._id); } @@ -172,3 +226,15 @@ Template.previewClipboardImagePopup.events({ } }, }); + +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} diff --git a/client/components/cards/attachments.styl b/client/components/cards/attachments.styl index 4a22fd8a..61ea8232 100644 --- a/client/components/cards/attachments.styl +++ b/client/components/cards/attachments.styl @@ -64,6 +64,17 @@ border: 1px solid black box-shadow: 0 1px 2px rgba(0,0,0,.2) +.uploading-info + .upload-progress-frame + background-color: grey; + border: 1px solid; + height: 22px; + + .upload-progress-bar + background-color: blue; + height: 20px; + padding: 1px; + @media screen and (max-width: 800px) .attachments-galery flex-direction diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index b6ccd4d7..e8efc6ac 100644 --- a/client/components/cards/minicard.jade +++ b/client/components/cards/minicard.jade @@ -11,7 +11,7 @@ template(name="minicard") .handle .fa.fa-arrows if cover - .minicard-cover(style="background-image: url('{{cover.url}}');") + .minicard-cover(style="background-image: url('{{coverUrl}}');") if labels .minicard-labels each labels diff --git a/client/components/cards/minicard.js b/client/components/cards/minicard.js index da36b87f..200e019d 100644 --- a/client/components/cards/minicard.js +++ b/client/components/cards/minicard.js @@ -52,4 +52,7 @@ Template.minicard.helpers({ return false; } }, + coverUrl() { + return Attachments.findOne(this.coverId).link('original', '/'); + }, }); diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 081c6521..de598da8 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -152,33 +152,31 @@ Template.editor.onRendered(() => { 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); + fileObj, + { onUploaded: + 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 = Attachments.link(attachment, 'original', '/'); + if (url) { + insertImage( + `${location.protocol}//${location.host}${url}`, + ); + } else { + retry++; + if (retry < maxTry) { + setTimeout(checkUrl, checkItvl); + } } - } - }; - checkUrl(); - }); + }; + checkUrl(); + }); + } } }, ); diff --git a/client/lib/popup.js b/client/lib/popup.js index 8095fbd2..cae22659 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -49,7 +49,7 @@ window.Popup = new (class { // has one. This allows us to position a sub-popup exactly at the same // position than its parent. let openerElement; - if (clickFromPopup(evt)) { + if (clickFromPopup(evt) && self._getTopStack()) { openerElement = self._getTopStack().openerElement; } else { self._stack = []; diff --git a/client/lib/utils.js b/client/lib/utils.js index c921fddc..d712cc73 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -61,30 +61,36 @@ 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); - } - }; + processUploadedAttachment(card, fileObj, callbacks) { if (!card) { - return next(); + return onUploaded(); } - const file = new FS.File(fileObj); + let settings = { + file: fileObj, + streams: 'dynamic', + chunkSize: 'dynamic', + }; + settings.meta = {}; if (card.isLinkedCard()) { - file.boardId = Cards.findOne(card.linkedId).boardId; - file.cardId = card.linkedId; + settings.meta.boardId = Cards.findOne(card.linkedId).boardId; + settings.meta.cardId = card.linkedId; } else { - file.boardId = card.boardId; - file.swimlaneId = card.swimlaneId; - file.listId = card.listId; - file.cardId = card._id; + settings.meta.boardId = card.boardId; + settings.meta.swimlaneId = card.swimlaneId; + settings.meta.listId = card.listId; + settings.meta.cardId = card._id; } - file.userId = Meteor.userId(); - if (file.original) { - file.original.name = fileObj.name; + settings.meta.userId = Meteor.userId(); + if (typeof callbacks === 'function') { + settings.onEnd = callbacks; + } else { + for (const key in callbacks) { + if (key.substring(0, 2) === 'on') { + settings[key] = callbacks[key]; + } + } } - return next(Attachments.insert(file)); + Attachments.insert(settings); }, shrinkImage(options) { // shrink image to certain size diff --git a/models/activities.js b/models/activities.js index b5fcb7d8..df207bca 100644 --- a/models/activities.js +++ b/models/activities.js @@ -217,7 +217,11 @@ if (Meteor.isServer) { } if (activity.attachmentId) { const attachment = activity.attachment(); - params.attachment = attachment.original.name; + if (attachment.original) { + params.attachment = attachment.original.name; + } else { + params.attachment = attachment.versions.original.name; + } params.attachmentId = attachment._id; } if (activity.checklistId) { diff --git a/models/attachments.js b/models/attachments.js index 3fe1d745..cab3d9e3 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -1,263 +1,97 @@ -const localFSStore = process.env.ATTACHMENTS_STORE_PATH; -const storeName = 'attachments'; -const defaultStoreOptions = { - beforeWrite: fileObj => { - if (!fileObj.isImage()) { - return { - type: 'application/octet-stream', - }; - } - return {}; - }, -}; -let store; -if (localFSStore) { - // have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem - const fs = Npm.require('fs'); - const path = Npm.require('path'); - const mongodb = Npm.require('mongodb'); - const Grid = Npm.require('gridfs-stream'); - // calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :( - let pathname = localFSStore; - /*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */ - - if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) { - pathname = path.join( - __meteor_bootstrap__.serverDir, - `../../../cfs/files/${storeName}`, - ); - } - - if (!pathname) - throw new Error('FS.Store.FileSystem unable to determine path'); - - // Check if we have '~/foo/bar' - if (pathname.split(path.sep)[0] === '~') { - const homepath = - process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; - if (homepath) { - pathname = pathname.replace('~', homepath); - } else { - throw new Error('FS.Store.FileSystem unable to resolve "~" in path'); - } - } - - // Set absolute path - const absolutePath = path.resolve(pathname); - - const _FStore = new FS.Store.FileSystem(storeName, { - path: localFSStore, - ...defaultStoreOptions, - }); - const GStore = { - fileKey(fileObj) { - const key = { - _id: null, - filename: null, - }; - - // If we're passed a fileObj, we retrieve the _id and filename from it. - if (fileObj) { - const info = fileObj._getInfo(storeName, { - updateFileRecordFirst: false, - }); - key._id = info.key || null; - key.filename = - info.name || - fileObj.name({ updateFileRecordFirst: false }) || - `${fileObj.collectionName}-${fileObj._id}`; - } - - // If key._id is null at this point, createWriteStream will let GridFS generate a new ID - return key; - }, - db: undefined, - mongoOptions: { useNewUrlParser: true }, - mongoUrl: process.env.MONGO_URL, - init() { - this._init(err => { - this.inited = !err; - }); - }, - _init(callback) { - const self = this; - mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function( - err, - db, - ) { - if (err) { - return callback(err); - } - self.db = db; - return callback(null); - }); - return; - }, - createReadStream(fileKey, options) { - const self = this; - if (!self.inited) { - self.init(); - return undefined; - } - options = options || {}; - - // Init GridFS - const gfs = new Grid(self.db, mongodb); - - // Set the default streamning settings - const settings = { - _id: new mongodb.ObjectID(fileKey._id), - root: `cfs_gridfs.${storeName}`, - }; - - // Check if this should be a partial read - if ( - typeof options.start !== 'undefined' && - typeof options.end !== 'undefined' - ) { - // Add partial info - settings.range = { - startPos: options.start, - endPos: options.end, - }; - } - return gfs.createReadStream(settings); - }, - }; - GStore.init(); - const CRS = 'createReadStream'; - const _CRS = `_${CRS}`; - const FStore = _FStore._transform; - FStore[_CRS] = FStore[CRS].bind(FStore); - FStore[CRS] = function(fileObj, options) { - let stream; - try { - const localFile = path.join( - absolutePath, - FStore.storage.fileKey(fileObj), - ); - const state = fs.statSync(localFile); - if (state) { - stream = FStore[_CRS](fileObj, options); - } - } catch (e) { - // file is not there, try GridFS ? - stream = undefined; - } - if (stream) return stream; - else { - try { - const stream = GStore[CRS](GStore.fileKey(fileObj), options); - return stream; - } catch (e) { - return undefined; - } - } - }.bind(FStore); - store = _FStore; -} else { - store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, { - // XXX Add a new store for cover thumbnails so we don't load big images in - // the general board view - // If the uploaded document is not an image we need to enforce browser - // download instead of execution. This is particularly important for HTML - // files that the browser will just execute if we don't serve them with the - // appropriate `application/octet-stream` MIME header which can lead to user - // data leaks. I imagine other formats (like PDF) can also be attack vectors. - // See https://github.com/wekan/wekan/issues/99 - // XXX Should we use `beforeWrite` option of CollectionFS instead of - // collection-hooks? - // We should use `beforeWrite`. - ...defaultStoreOptions, - }); -} -Attachments = new FS.Collection('attachments', { - stores: [store], +import { FilesCollection } from 'meteor/ostrio:files'; + +const collectionName = 'attachments2'; + +Attachments = new FilesCollection({ + storagePath: storagePath(), + debug: false, + allowClientCode: true, + collectionName: 'attachments2', + onAfterUpload: onAttachmentUploaded, + onBeforeRemove: onAttachmentRemoving, + onAfterRemove: onAttachmentRemoved }); if (Meteor.isServer) { Meteor.startup(() => { - Attachments.files._ensureIndex({ cardId: 1 }); + Attachments.collection._ensureIndex({ cardId: 1 }); }); - Attachments.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); - } - }, + // TODO: Permission related + // TODO: Add Activity update - fetch: ['boardId'], + Meteor.publish(collectionName, function() { + return Attachments.find().cursor; }); +} else { + Meteor.subscribe(collectionName); } -// XXX Enforce a schema for the Attachments CollectionFS +function storagePath(defaultPath) { + const storePath = process.env.ATTACHMENTS_STORE_PATH; + return storePath ? storePath : defaultPath; +} -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: '', - }, +function onAttachmentUploaded(fileRef) { + Attachments.update({_id:fileRef._id}, {$set: {"meta.uploaded": true}}); + if (!fileRef.meta.source || fileRef.meta.source !== 'import') { + // Add activity about adding the attachment + Activities.insert({ + userId: fileRef.userId, + type: 'card', + activityType: 'addAttachment', + attachmentId: fileRef._id, + // this preserves the name so that notifications can be meaningful after + // this file is removed + attachmentName: fileRef.versions.original.name, + boardId: fileRef.meta.boardId, + cardId: fileRef.meta.cardId, + listId: fileRef.meta.listId, + swimlaneId: fileRef.meta.swimlaneId, + }); + } else { + // Don't add activity about adding the attachment as the activity + // be imported and delete source field + CFSAttachments.update( + { + _id: fileRef._id, + }, + { + $unset: { + source: '', }, - ); - } + }, + ); + } +} + +function onAttachmentRemoving(cursor) { + const file = cursor.get()[0]; + const meta = file.meta; + Activities.insert({ + userId: this.userId, + type: 'card', + activityType: 'deleteAttachment', + attachmentId: file._id, + // this preserves the name so that notifications can be meaningful after + // this file is removed + attachmentName: file.versions.original.name, + boardId: meta.boardId, + cardId: meta.cardId, + listId: meta.listId, + swimlaneId: meta.swimlaneId, }); + return true; +} - Attachments.files.before.remove((userId, doc) => { - Activities.insert({ - userId, - type: 'card', - activityType: 'deleteAttachment', +function onAttachmentRemoved(files) { + // Don't know why we need to remove the activity +/* for (let i in files) { + let doc = files[i]; + Activities.remove({ 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; diff --git a/models/cards.js b/models/cards.js index 4197f7ab..1236de1a 100644 --- a/models/cards.js +++ b/models/cards.js @@ -403,7 +403,7 @@ Cards.helpers({ // Copy attachments oldCard.attachments().forEach(att => { - att.cardId = _id; + att.meta.cardId = _id; delete att._id; return Attachments.insert(att); }); @@ -493,14 +493,15 @@ Cards.helpers({ attachments() { if (this.isLinkedCard()) { return Attachments.find( - { cardId: this.linkedId }, + { 'meta.cardId': this.linkedId }, { sort: { uploadedAt: -1 } }, ); } else { - return Attachments.find( - { cardId: this._id }, + let ret = Attachments.find( + { 'meta.cardId': this._id }, { sort: { uploadedAt: -1 } }, ); + return ret; } }, @@ -508,7 +509,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(); }, checklists() { diff --git a/models/export.js b/models/export.js index 339123c8..35e55804 100644 --- a/models/export.js +++ b/models/export.js @@ -165,7 +165,7 @@ export class Exporter { readStream.pipe(tmpWriteable); }; const getBase64DataSync = Meteor.wrapAsync(getBase64Data); - result.attachments = Attachments.find(byBoard) + result.attachments = Attachments.find({ 'meta.boardId': byBoard.boardId }) .fetch() .map(attachment => { let filebase64 = null; diff --git a/models/trelloCreator.js b/models/trelloCreator.js index 1c5bcd93..c4be140b 100644 --- a/models/trelloCreator.js +++ b/models/trelloCreator.js @@ -369,6 +369,7 @@ export class TrelloCreator { // so we make it server only, and let UI catch up once it is done, forget about latency comp. const self = this; if (Meteor.isServer) { + // FIXME: Change to new model file.attachData(att.url, function(error) { file.boardId = boardId; file.cardId = cardId; diff --git a/models/wekanCreator.js b/models/wekanCreator.js index 9914f817..c5591a0b 100644 --- a/models/wekanCreator.js +++ b/models/wekanCreator.js @@ -415,6 +415,7 @@ export class WekanCreator { const self = this; if (Meteor.isServer) { if (att.url) { + // FIXME: Change to new file library file.attachData(att.url, function(error) { file.boardId = boardId; file.cardId = cardId; @@ -440,6 +441,7 @@ export class WekanCreator { } }); } else if (att.file) { + // FIXME: Change to new file library file.attachData( Buffer.from(att.file, 'base64'), { diff --git a/package-lock.json b/package-lock.json index 72e781a7..5b22af78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1600,6 +1600,14 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fibers": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/fibers/-/fibers-4.0.2.tgz", + "integrity": "sha512-FhICi1K4WZh9D6NC18fh2ODF3EWy1z0gzIdV9P7+s2pRjfRBnCkMDJ6x3bV1DkVymKH8HGrQa/FNOBjYvnJ/tQ==", + "requires": { + "detect-libc": "^1.0.3" + } + }, "figures": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", diff --git a/rebuild-wekan.bat b/rebuild-wekan.bat index 49d0fd58..0d88d948 100644 --- a/rebuild-wekan.bat +++ b/rebuild-wekan.bat @@ -1,64 +1,64 @@ -@ECHO OFF
-
-REM NOTE: You can try this to install Meteor on Windows, it works:
-REM https://github.com/zodern/windows-meteor-installer/
-
-REM Installing Meteor with Chocolatey does not currently work.
-
-REM NOTE: THIS .BAT DOES NOT WORK !!
-REM Use instead this webpage instructions to build on Windows:
-REM https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows
-REM Please add fix PRs, like config of MongoDB etc.
-
-md C:\repos
-cd C:\repos
-
-REM Install chocolatey
-@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
-
-choco install -y git curl python2 dotnet4.5.2 nano mongodb-3 mongoclient meteor
-
-curl -O https://nodejs.org/dist/v12.16.3/node-v12.16.2-x64.msi
-call node-v12.16.3-x64.msi
-
-call npm config -g set msvs_version 2015
-call meteor npm config -g set msvs_version 2015
-
-call npm -g install npm
-call npm -g install node-gyp
-call npm -g install fibers
-cd C:\repos
-git clone https://github.com/wekan/wekan.git
-cd wekan
-git checkout edge
-echo "Building Wekan."
-REM del /S /F /Q packages
-REM ## REPOS BELOW ARE INCLUDED TO WEKAN
-REM md packages
-REM cd packages
-REM git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router
-REM git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core
-REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git
-REM git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git
-REM git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git
-REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git
-REM git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git
-REM move meteor-accounts-oidc/packages/switch_accounts-oidc wekan_accounts-oidc
-REM move meteor-accounts-oidc/packages/switch_oidc wekan_oidc
-REM del /S /F /Q meteor-accounts-oidc
-REM sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' ~/repos/wekan/packages/meteor-useraccounts-core/package.js
-cd ..
-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
-cd .build\bundle\programs\server
-call meteor npm install
-REM cd C:\repos\wekan\.meteor\local\build\programs\server
-REM del node_modules
-cd C:\repos\wekan
-call start-wekan.bat
+@ECHO OFF + +REM NOTE: You can try this to install Meteor on Windows, it works: +REM https://github.com/zodern/windows-meteor-installer/ + +REM Installing Meteor with Chocolatey does not currently work. + +REM NOTE: THIS .BAT DOES NOT WORK !! +REM Use instead this webpage instructions to build on Windows: +REM https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows +REM Please add fix PRs, like config of MongoDB etc. + +md C:\repos +cd C:\repos + +REM Install chocolatey +@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin" + +choco install -y git curl python2 dotnet4.5.2 nano mongodb-3 mongoclient meteor + +curl -O https://nodejs.org/dist/v12.16.3/node-v12.16.2-x64.msi +call node-v12.16.3-x64.msi + +call npm config -g set msvs_version 2015 +call meteor npm config -g set msvs_version 2015 + +call npm -g install npm +call npm -g install node-gyp +call npm -g install fibers +cd C:\repos +git clone https://github.com/wekan/wekan.git +cd wekan +git checkout edge +echo "Building Wekan." +REM del /S /F /Q packages +REM ## REPOS BELOW ARE INCLUDED TO WEKAN +REM md packages +REM cd packages +REM git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router +REM git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core +REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git +REM git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git +REM git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git +REM git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git +REM git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git +REM move meteor-accounts-oidc/packages/switch_accounts-oidc wekan_accounts-oidc +REM move meteor-accounts-oidc/packages/switch_oidc wekan_oidc +REM del /S /F /Q meteor-accounts-oidc +REM sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' ~/repos/wekan/packages/meteor-useraccounts-core/package.js +cd .. +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 +cd .build\bundle\programs\server +call meteor npm install +REM cd C:\repos\wekan\.meteor\local\build\programs\server +REM del node_modules +cd C:\repos\wekan +call start-wekan.bat diff --git a/sandstorm-pkgdef.capnp b/sandstorm-pkgdef.capnp index 64ab1078..eb6c7c5b 100644 --- a/sandstorm-pkgdef.capnp +++ b/sandstorm-pkgdef.capnp @@ -261,6 +261,7 @@ const myCommand :Spk.Manifest.Command = ( (key = "LDAP_ENABLE", value="false"), (key = "PASSWORD_LOGIN_ENABLED", value="true"), (key = "SANDSTORM", value="1"), - (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}") + (key = "METEOR_SETTINGS", value = "{\"public\": {\"sandstorm\": true}}"), + (key = "ATTACHMENTS_STORE_PATH", value = "/var/attachments/") ] ); diff --git a/server/migrations.js b/server/migrations.js index a6c945fa..e330f043 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -1041,3 +1041,46 @@ Migrations.add('add-sort-field-to-boards', () => { } }); }); + +import { MongoInternals } from 'meteor/mongo'; + +Migrations.add('change-attachment-library', () => { + const http = require('http'); + const fs = require('fs'); + CFSAttachments.find().forEach(file => { + const bucket = new MongoInternals.NpmModule.GridFSBucket(MongoInternals.defaultRemoteCollectionDriver().mongo.db, {bucketName: 'cfs_gridfs.attachments'}); + const gfsId = new MongoInternals.NpmModule.ObjectID(file.copies.attachments.key); + const reader = bucket.openDownloadStream(gfsId); + let store = Attachments.storagePath(); + if (store.charAt(store.length - 1) === '/') { + store = store.substring(0, store.length - 1); + } + const path = `${store}/${file.name()}`; + const fd = fs.createWriteStream(path); + reader.pipe(fd); + let opts = { + fileName: file.name(), + type: file.type(), + fileId: file._id, + meta: { + userId: file.userId, + boardId: file.boardId, + cardId: file.cardId + } + }; + if (file.listId) { + opts.meta.listId = file.listId; + } + if (file.swimlaneId) { + opts.meta.swimlaneId = file.swimlaneId; + } + Attachments.addFile(path, opts, (err, fileRef) => { + if (err) { + console.log('error when migrating ', fileName, err); + } else { + file.remove(); + } + }); + }); +}); + diff --git a/server/old-attachments-migration.js b/server/old-attachments-migration.js new file mode 100644 index 00000000..3a6aa85d --- /dev/null +++ b/server/old-attachments-migration.js @@ -0,0 +1,212 @@ +const localFSStore = process.env.ATTACHMENTS_STORE_PATH; +const storeName = 'attachments'; +const defaultStoreOptions = { + beforeWrite: fileObj => { + if (!fileObj.isImage()) { + return { + type: 'application/octet-stream', + }; + } + return {}; + }, +}; +let store; +if (localFSStore) { + // have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem + const fs = Npm.require('fs'); + const path = Npm.require('path'); + const mongodb = Npm.require('mongodb'); + const Grid = Npm.require('gridfs-stream'); + // calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :( + let pathname = localFSStore; + /*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */ + + if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) { + pathname = path.join( + __meteor_bootstrap__.serverDir, + `../../../cfs/files/${storeName}`, + ); + } + + if (!pathname) + throw new Error('FS.Store.FileSystem unable to determine path'); + + // Check if we have '~/foo/bar' + if (pathname.split(path.sep)[0] === '~') { + const homepath = + process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE; + if (homepath) { + pathname = pathname.replace('~', homepath); + } else { + throw new Error('FS.Store.FileSystem unable to resolve "~" in path'); + } + } + + // Set absolute path + const absolutePath = path.resolve(pathname); + + const _FStore = new FS.Store.FileSystem(storeName, { + path: localFSStore, + ...defaultStoreOptions, + }); + const GStore = { + fileKey(fileObj) { + const key = { + _id: null, + filename: null, + }; + + // If we're passed a fileObj, we retrieve the _id and filename from it. + if (fileObj) { + const info = fileObj._getInfo(storeName, { + updateFileRecordFirst: false, + }); + key._id = info.key || null; + key.filename = + info.name || + fileObj.name({ updateFileRecordFirst: false }) || + `${fileObj.collectionName}-${fileObj._id}`; + } + + // If key._id is null at this point, createWriteStream will let GridFS generate a new ID + return key; + }, + db: undefined, + mongoOptions: { useNewUrlParser: true }, + mongoUrl: process.env.MONGO_URL, + init() { + this._init(err => { + this.inited = !err; + }); + }, + _init(callback) { + const self = this; + mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function( + err, + db, + ) { + if (err) { + return callback(err); + } + self.db = db; + return callback(null); + }); + return; + }, + createReadStream(fileKey, options) { + const self = this; + if (!self.inited) { + self.init(); + return undefined; + } + options = options || {}; + + // Init GridFS + const gfs = new Grid(self.db, mongodb); + + // Set the default streamning settings + const settings = { + _id: new mongodb.ObjectID(fileKey._id), + root: `cfs_gridfs.${storeName}`, + }; + + // Check if this should be a partial read + if ( + typeof options.start !== 'undefined' && + typeof options.end !== 'undefined' + ) { + // Add partial info + settings.range = { + startPos: options.start, + endPos: options.end, + }; + } + return gfs.createReadStream(settings); + }, + }; + GStore.init(); + const CRS = 'createReadStream'; + const _CRS = `_${CRS}`; + const FStore = _FStore._transform; + FStore[_CRS] = FStore[CRS].bind(FStore); + FStore[CRS] = function(fileObj, options) { + let stream; + try { + const localFile = path.join( + absolutePath, + FStore.storage.fileKey(fileObj), + ); + const state = fs.statSync(localFile); + if (state) { + stream = FStore[_CRS](fileObj, options); + } + } catch (e) { + // file is not there, try GridFS ? + stream = undefined; + } + if (stream) return stream; + else { + try { + const stream = GStore[CRS](GStore.fileKey(fileObj), options); + return stream; + } catch (e) { + return undefined; + } + } + }.bind(FStore); + store = _FStore; +} else { + store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, { + // XXX Add a new store for cover thumbnails so we don't load big images in + // the general board view + // If the uploaded document is not an image we need to enforce browser + // download instead of execution. This is particularly important for HTML + // files that the browser will just execute if we don't serve them with the + // appropriate `application/octet-stream` MIME header which can lead to user + // data leaks. I imagine other formats (like PDF) can also be attack vectors. + // See https://github.com/wekan/wekan/issues/99 + // XXX Should we use `beforeWrite` option of CollectionFS instead of + // collection-hooks? + // We should use `beforeWrite`. + ...defaultStoreOptions, + }); +} +CFSAttachments = new FS.Collection('attachments', { + stores: [store], +}); + +if (Meteor.isServer) { + Meteor.startup(() => { + CFSAttachments.files._ensureIndex({ cardId: 1 }); + }); + + CFSAttachments.allow({ + insert(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + update(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + remove(userId, doc) { + return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); + }, + // We authorize the attachment download either: + // - if the board is public, everyone (even unconnected) can download it + // - if the board is private, only board members can download it + download(userId, doc) { + if (Meteor.isServer) { + return true; + } + const board = Boards.findOne(doc.boardId); + if (board.isPublic()) { + return true; + } else { + return board.hasMember(userId); + } + }, + + fetch: ['boardId'], + }); +} + +export default CFSAttachments; diff --git a/server/publications/boards.js b/server/publications/boards.js index b80a6b23..ae82d28c 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -131,7 +131,7 @@ Meteor.publishRelations('board', function(boardId, isArchived) { // Gather queries and send in bulk const cardComments = this.join(CardComments); cardComments.selector = _ids => ({ cardId: _ids }); - const attachments = this.join(Attachments); + const attachments = this.join(Attachments.collection); attachments.selector = _ids => ({ cardId: _ids }); const checklists = this.join(Checklists); checklists.selector = _ids => ({ cardId: _ids }); |