summaryrefslogtreecommitdiffstats
path: root/models/attachments.js
blob: 3fe1d745eb8544241f6a973a1aeff4a628961038 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
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],
});

if (Meteor.isServer) {
  Meteor.startup(() => {
    Attachments.files._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);
      }
    },

    fetch: ['boardId'],
  });
}

// XXX Enforce a schema for the Attachments CollectionFS

if (Meteor.isServer) {
  Attachments.files.after.insert((userId, doc) => {
    // If the attachment doesn't have a source field
    // or its source is different than import
    if (!doc.source || doc.source !== 'import') {
      // Add activity about adding the attachment
      Activities.insert({
        userId,
        type: 'card',
        activityType: 'addAttachment',
        attachmentId: doc._id,
        // this preserves the name so that notifications can be meaningful after
        // this file is removed
        attachmentName: doc.original.name,
        boardId: doc.boardId,
        cardId: doc.cardId,
        listId: doc.listId,
        swimlaneId: doc.swimlaneId,
      });
    } else {
      // Don't add activity about adding the attachment as the activity
      // be imported and delete source field
      Attachments.update(
        {
          _id: doc._id,
        },
        {
          $unset: {
            source: '',
          },
        },
      );
    }
  });

  Attachments.files.before.remove((userId, doc) => {
    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;