attachments.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. export const AttachmentStorage = new Mongo.Collection(
  2. 'cfs_gridfs.attachments.files',
  3. );
  4. export const AvatarStorage = new Mongo.Collection('cfs_gridfs.avatars.files');
  5. const localFSStore = process.env.ATTACHMENTS_STORE_PATH;
  6. const storeName = 'attachments';
  7. const defaultStoreOptions = {
  8. beforeWrite: fileObj => {
  9. if (!fileObj.isImage()) {
  10. return {
  11. type: 'application/octet-stream',
  12. };
  13. }
  14. return {};
  15. },
  16. };
  17. let store;
  18. if (localFSStore) {
  19. // have to reinvent methods from FS.Store.GridFS and FS.Store.FileSystem
  20. const fs = Npm.require('fs');
  21. const path = Npm.require('path');
  22. const mongodb = Npm.require('mongodb');
  23. const Grid = Npm.require('gridfs-stream');
  24. // calulate the absolute path here, because FS.Store.FileSystem didn't expose the aboslutepath or FS.Store didn't expose api calls :(
  25. let pathname = localFSStore;
  26. /*eslint camelcase: ["error", {allow: ["__meteor_bootstrap__"]}] */
  27. if (!pathname && __meteor_bootstrap__ && __meteor_bootstrap__.serverDir) {
  28. pathname = path.join(
  29. __meteor_bootstrap__.serverDir,
  30. `../../../cfs/files/${storeName}`,
  31. );
  32. }
  33. if (!pathname)
  34. throw new Error('FS.Store.FileSystem unable to determine path');
  35. // Check if we have '~/foo/bar'
  36. if (pathname.split(path.sep)[0] === '~') {
  37. const homepath =
  38. process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
  39. if (homepath) {
  40. pathname = pathname.replace('~', homepath);
  41. } else {
  42. throw new Error('FS.Store.FileSystem unable to resolve "~" in path');
  43. }
  44. }
  45. // Set absolute path
  46. const absolutePath = path.resolve(pathname);
  47. const _FStore = new FS.Store.FileSystem(storeName, {
  48. path: localFSStore,
  49. ...defaultStoreOptions,
  50. });
  51. const GStore = {
  52. fileKey(fileObj) {
  53. const key = {
  54. _id: null,
  55. filename: null,
  56. };
  57. // If we're passed a fileObj, we retrieve the _id and filename from it.
  58. if (fileObj) {
  59. const info = fileObj._getInfo(storeName, {
  60. updateFileRecordFirst: false,
  61. });
  62. key._id = info.key || null;
  63. key.filename =
  64. info.name ||
  65. fileObj.name({ updateFileRecordFirst: false }) ||
  66. `${fileObj.collectionName}-${fileObj._id}`;
  67. }
  68. // If key._id is null at this point, createWriteStream will let GridFS generate a new ID
  69. return key;
  70. },
  71. db: undefined,
  72. mongoOptions: { useNewUrlParser: true },
  73. mongoUrl: process.env.MONGO_URL,
  74. init() {
  75. this._init(err => {
  76. this.inited = !err;
  77. });
  78. },
  79. _init(callback) {
  80. const self = this;
  81. mongodb.MongoClient.connect(self.mongoUrl, self.mongoOptions, function(
  82. err,
  83. db,
  84. ) {
  85. if (err) {
  86. return callback(err);
  87. }
  88. self.db = db;
  89. return callback(null);
  90. });
  91. return;
  92. },
  93. createReadStream(fileKey, options) {
  94. const self = this;
  95. if (!self.inited) {
  96. self.init();
  97. return undefined;
  98. }
  99. options = options || {};
  100. // Init GridFS
  101. const gfs = new Grid(self.db, mongodb);
  102. // Set the default streamning settings
  103. const settings = {
  104. _id: new mongodb.ObjectID(fileKey._id),
  105. root: `cfs_gridfs.${storeName}`,
  106. };
  107. // Check if this should be a partial read
  108. if (
  109. typeof options.start !== 'undefined' &&
  110. typeof options.end !== 'undefined'
  111. ) {
  112. // Add partial info
  113. settings.range = {
  114. startPos: options.start,
  115. endPos: options.end,
  116. };
  117. }
  118. return gfs.createReadStream(settings);
  119. },
  120. };
  121. GStore.init();
  122. const CRS = 'createReadStream';
  123. const _CRS = `_${CRS}`;
  124. const FStore = _FStore._transform;
  125. FStore[_CRS] = FStore[CRS].bind(FStore);
  126. FStore[CRS] = function(fileObj, options) {
  127. let stream;
  128. try {
  129. const localFile = path.join(
  130. absolutePath,
  131. FStore.storage.fileKey(fileObj),
  132. );
  133. const state = fs.statSync(localFile);
  134. if (state) {
  135. stream = FStore[_CRS](fileObj, options);
  136. }
  137. } catch (e) {
  138. // file is not there, try GridFS ?
  139. stream = undefined;
  140. }
  141. if (stream) return stream;
  142. else {
  143. try {
  144. const stream = GStore[CRS](GStore.fileKey(fileObj), options);
  145. return stream;
  146. } catch (e) {
  147. return undefined;
  148. }
  149. }
  150. }.bind(FStore);
  151. store = _FStore;
  152. } else {
  153. store = new FS.Store.GridFS(localFSStore ? `G${storeName}` : storeName, {
  154. // XXX Add a new store for cover thumbnails so we don't load big images in
  155. // the general board view
  156. // If the uploaded document is not an image we need to enforce browser
  157. // download instead of execution. This is particularly important for HTML
  158. // files that the browser will just execute if we don't serve them with the
  159. // appropriate `application/octet-stream` MIME header which can lead to user
  160. // data leaks. I imagine other formats (like PDF) can also be attack vectors.
  161. // See https://github.com/wekan/wekan/issues/99
  162. // XXX Should we use `beforeWrite` option of CollectionFS instead of
  163. // collection-hooks?
  164. // We should use `beforeWrite`.
  165. ...defaultStoreOptions,
  166. });
  167. }
  168. Attachments = new FS.Collection('attachments', {
  169. stores: [store],
  170. });
  171. if (Meteor.isServer) {
  172. Meteor.startup(() => {
  173. Attachments.files._ensureIndex({ cardId: 1 });
  174. });
  175. Attachments.allow({
  176. insert(userId, doc) {
  177. return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
  178. },
  179. update(userId, doc) {
  180. return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
  181. },
  182. remove(userId, doc) {
  183. return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
  184. },
  185. // We authorize the attachment download either:
  186. // - if the board is public, everyone (even unconnected) can download it
  187. // - if the board is private, only board members can download it
  188. download(userId, doc) {
  189. const board = Boards.findOne(doc.boardId);
  190. if (board.isPublic()) {
  191. return true;
  192. } else {
  193. return board.hasMember(userId);
  194. }
  195. },
  196. fetch: ['boardId'],
  197. });
  198. }
  199. // XXX Enforce a schema for the Attachments CollectionFS
  200. if (Meteor.isServer) {
  201. Attachments.files.after.insert((userId, doc) => {
  202. // If the attachment doesn't have a source field
  203. // or its source is different than import
  204. if (!doc.source || doc.source !== 'import') {
  205. // Add activity about adding the attachment
  206. Activities.insert({
  207. userId,
  208. type: 'card',
  209. activityType: 'addAttachment',
  210. attachmentId: doc._id,
  211. // this preserves the name so that notifications can be meaningful after
  212. // this file is removed
  213. attachmentName: doc.original.name,
  214. boardId: doc.boardId,
  215. cardId: doc.cardId,
  216. listId: doc.listId,
  217. swimlaneId: doc.swimlaneId,
  218. });
  219. } else {
  220. // Don't add activity about adding the attachment as the activity
  221. // be imported and delete source field
  222. Attachments.update(
  223. {
  224. _id: doc._id,
  225. },
  226. {
  227. $unset: {
  228. source: '',
  229. },
  230. },
  231. );
  232. }
  233. });
  234. Attachments.files.before.remove((userId, doc) => {
  235. Activities.insert({
  236. userId,
  237. type: 'card',
  238. activityType: 'deleteAttachment',
  239. attachmentId: doc._id,
  240. // this preserves the name so that notifications can be meaningful after
  241. // this file is removed
  242. attachmentName: doc.original.name,
  243. boardId: doc.boardId,
  244. cardId: doc.cardId,
  245. listId: doc.listId,
  246. swimlaneId: doc.swimlaneId,
  247. });
  248. });
  249. }
  250. export default Attachments;