wingbot-mongodb 2.16.3 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @author wingbot.ai
3
+ */
4
+ 'use strict';
5
+
6
+ const jsonwebtoken = require('jsonwebtoken');
7
+ const BaseStorage = require('./BaseStorage');
8
+
9
+ /** @typedef {import('mongodb/lib/db')} Db */
10
+
11
+ const LEVEL_CRITICAL = 'Critical';
12
+ const LEVEL_IMPORTANT = 'Important';
13
+ const LEVEL_DEBUG = 'Debug';
14
+
15
+ const TYPE_ERROR = 'Error';
16
+ const TYPE_WARN = 'Warn';
17
+ const TYPE_INFO = 'Info';
18
+
19
+ /**
20
+ * @typedef {object} TrackingEvent
21
+ * @prop {string} [type='audit']
22
+ * @prop {string} category
23
+ * @prop {string} action
24
+ * @prop {string} [label]
25
+ * @prop {object} [payload]
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} User
30
+ * @prop {string} [id]
31
+ * @prop {string} [senderId]
32
+ * @prop {string} [pageId]
33
+ * @prop {string} [jwt] - jwt to check the authorship
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} Meta
38
+ * @prop {string} [ip]
39
+ * @prop {string} [ua]
40
+ * @prop {string} [ro] - referrer || origin
41
+ */
42
+
43
+ /**
44
+ * @typedef {object} LogEntry
45
+ * @prop {string} date - ISO date
46
+ * @prop {number} delta - time skew in ms if there was a write conflict
47
+ * @prop {string} [eventType='audit']
48
+ * @prop {string} category
49
+ * @prop {string} action
50
+ * @prop {string} [label]
51
+ * @prop {object} [payload]
52
+ * @prop {string} level - (Critical|Important|Debug)
53
+ * @prop {boolean} ok - signature matches
54
+ * @prop {number} seq - sequence number
55
+ * @prop {string} type - (Error|Warn|Info)
56
+ * @prop {User} user
57
+ * @prop {string} wid - workspace id
58
+ * @prop {Meta} meta
59
+ */
60
+
61
+ /**
62
+ * JWT Verifier
63
+ *
64
+ * @callback JwtVerifier
65
+ * @param {string} token
66
+ * @param {string} userId
67
+ * @param {User} [user]
68
+ * @returns {Promise<boolean>}
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} AuditLogEntry
73
+ * @prop {string} date - ISO date
74
+ * @prop {string} [eventType='audit']
75
+ * @prop {string} category
76
+ * @prop {string} action
77
+ * @prop {string} [label]
78
+ * @prop {object} [payload]
79
+ * @prop {string} level - (Critical|Important|Debug)
80
+ * @prop {string} type - (Error|Warn|Info)
81
+ * @prop {User} user
82
+ * @prop {string} wid - workspace id
83
+ * @prop {Meta} meta
84
+ */
85
+
86
+ /**
87
+ * Audit Log Callback
88
+ *
89
+ * @callback AuditLogCallback
90
+ * @param {AuditLogEntry} entry
91
+ * @returns {Promise}
92
+ */
93
+
94
+ /**
95
+ * Storage for audit logs with signatures chain
96
+ */
97
+ class AuditLogStorage extends BaseStorage {
98
+
99
+ /**
100
+ *
101
+ * @param {Db|{():Promise<Db>}} mongoDb
102
+ * @param {string} collectionName
103
+ * @param {{error:Function,log:Function}} [log] - console like logger
104
+ * @param {boolean} [isCosmo]
105
+ * @param {string|Promise<string>} [secret]
106
+ * @param {string|Promise<string>} [jwtVerifier]
107
+ */
108
+ constructor (mongoDb, collectionName = 'auditlog', log = console, isCosmo = false, secret = null, jwtVerifier = null) {
109
+ super(mongoDb, collectionName, log, isCosmo);
110
+
111
+ this.addIndex({
112
+ wid: 1,
113
+ seq: -1
114
+ }, {
115
+ unique: true,
116
+ name: 'wid_1_seq_-1'
117
+ });
118
+
119
+ if (isCosmo) {
120
+ this.addIndex({
121
+ wid: 1
122
+ }, {
123
+ name: 'wid_1'
124
+ });
125
+
126
+ this.addIndex({
127
+ seq: -1
128
+ }, {
129
+ name: 'seq_-1'
130
+ });
131
+ } else {
132
+ this.addIndex({
133
+ wid: 1, date: -1
134
+ }, {
135
+ name: 'wid_1_date_-1'
136
+ });
137
+ }
138
+
139
+ this.defaultWid = '0';
140
+
141
+ this.muteErrors = true;
142
+ this.maxRetries = 4;
143
+ this._secret = secret;
144
+
145
+ this.LEVEL_CRITICAL = LEVEL_CRITICAL;
146
+ this.LEVEL_IMPORTANT = LEVEL_IMPORTANT;
147
+ this.LEVEL_DEBUG = LEVEL_DEBUG;
148
+
149
+ this.TYPE_ERROR = TYPE_ERROR;
150
+ this.TYPE_WARN = TYPE_WARN;
151
+ this.TYPE_INFO = TYPE_INFO;
152
+
153
+ /**
154
+ * @type {JwtVerifier}
155
+ */
156
+ // @ts-ignore
157
+ this._jwtVerify = typeof jwtVerifier === 'function' || jwtVerifier === null
158
+ ? jwtVerifier
159
+ : async (token, userId) => {
160
+ const jwtSec = await Promise.resolve(jwtVerifier);
161
+ const decoded = await new Promise((resolve) => {
162
+ jsonwebtoken.verify(token, jwtSec, (err, res) => {
163
+ resolve(res || {});
164
+ });
165
+ });
166
+ return decoded.id === userId;
167
+ };
168
+
169
+ /** @type {AuditLogCallback} */
170
+ this.callback = null;
171
+ }
172
+
173
+ /**
174
+ * Add a log
175
+ *
176
+ * @param {TrackingEvent} event
177
+ * @param {User} user
178
+ * @param {Meta} [meta]
179
+ * @param {string} [wid] - workspace ID
180
+ * @param {string} [type]
181
+ * @param {string} [level]
182
+ * @param {Date} [date]
183
+ * @returns {Promise}
184
+ */
185
+ async log (
186
+ event,
187
+ user = {},
188
+ meta = {},
189
+ wid = this.defaultWid,
190
+ type = TYPE_INFO,
191
+ level = LEVEL_IMPORTANT,
192
+ date = new Date()
193
+ ) {
194
+ const {
195
+ type: eventType = 'audit',
196
+ ...rest
197
+ } = event;
198
+ const entry = {
199
+ date,
200
+ eventType,
201
+ ...rest,
202
+ level,
203
+ meta,
204
+ type,
205
+ user,
206
+ wid
207
+ };
208
+
209
+ const secret = await Promise.resolve(this._secret);
210
+ const stored = await this._storeWithRetry(secret, entry);
211
+ if (!this.callback) {
212
+ return;
213
+ }
214
+ try {
215
+ await this.callback(stored);
216
+ } catch (e) {
217
+ this._log.error('Failed to send AuditLog', e, entry);
218
+ }
219
+
220
+ // logEvent(level, type, workflowType, workflowInstance, eventData, account)
221
+ // level is the criticality of the event ('Critical','Important','Debug').
222
+ // type is the type of the event ('Error','Warn','Info').
223
+ /**
224
+ * - log (N/A)
225
+ - report (Debug)
226
+ - conversation (N/A)
227
+ - audit (Important)
228
+ - user (N/A)
229
+ */
230
+ }
231
+
232
+ /**
233
+ *
234
+ * @param {string} [wid] - workspace id
235
+ * @param {number} [fromSeq] - for paging
236
+ * @param {number} [limit]
237
+ * @returns {Promise<LogEntry[]>}
238
+ */
239
+ async list (wid = this.defaultWid, fromSeq = 0, limit = 40) {
240
+ const c = await this._getCollection();
241
+
242
+ const cond = { wid };
243
+
244
+ if (fromSeq) {
245
+ cond.seq = { $lt: fromSeq };
246
+ }
247
+
248
+ const data = await c.find({ wid })
249
+ .limit(limit + 1)
250
+ .sort({ seq: -1 })
251
+ .project({ _id: 0 })
252
+ .toArray();
253
+
254
+ const secret = await Promise.resolve(this._secret);
255
+
256
+ const len = data.length === limit + 1
257
+ ? data.length - 1
258
+ : data.length;
259
+ const ret = new Array(len);
260
+
261
+ let verifyUser = false;
262
+ for (let i = 0; i < len; i++) {
263
+ const {
264
+ sign,
265
+ ...log
266
+ } = data[i];
267
+
268
+ verifyUser = verifyUser || (log.user.id && log.user.jwt);
269
+
270
+ if (secret) {
271
+ const previous = data[i + 1] || { sign: null };
272
+ const objToSign = this._objectToSign(log);
273
+ const compare = this._signWithSecret(objToSign, secret, previous.sign);
274
+ log.ok = compare === sign;
275
+ if (!log.ok) {
276
+ this._log.error(`AuditLog: found wrong signature at wid: "${log.wid}", seq: "${log.seq}"`, log);
277
+ }
278
+ } else {
279
+ log.ok = null;
280
+ }
281
+
282
+ ret[i] = log;
283
+ }
284
+
285
+ if (verifyUser && this._jwtVerify) {
286
+ return Promise.all(
287
+ ret.map(async (log) => {
288
+ if (!log.user.id || !log.user.jwt) {
289
+ return log;
290
+ }
291
+ const userChecked = await this._jwtVerify(log.user.jwt, log.user.id, log.user);
292
+ return Object.assign(log, {
293
+ // it's ok, when there was null
294
+ ok: log.ok !== false && userChecked
295
+ });
296
+ })
297
+ );
298
+ }
299
+
300
+ return ret;
301
+ }
302
+
303
+ _wait (ms) {
304
+ return new Promise((r) => setTimeout(r, ms));
305
+ }
306
+
307
+ async _storeWithRetry (secret, entry, delta = 0, i = 1) {
308
+ const start = Date.now();
309
+ Object.assign(entry, { delta });
310
+ try {
311
+ await this._store(entry, secret);
312
+ return entry;
313
+ } catch (e) {
314
+ if (e.code === 11000) {
315
+ // duplicate key
316
+ if (i >= this.maxRetries) {
317
+ throw new Error('AuditLog: cannot store log due to max-retries');
318
+ } else {
319
+ await this._wait((i * 50) + (Math.random() * 100));
320
+ const add = Date.now() - start;
321
+ return this._storeWithRetry(secret, entry, delta + add, i + 1);
322
+ }
323
+ } else if (this.muteErrors) {
324
+ this._log.error('Audit log store error', e, entry);
325
+ return entry;
326
+ } else {
327
+ throw e;
328
+ }
329
+ }
330
+ }
331
+
332
+ async _store (entry, secret) {
333
+ const c = await this._getCollection();
334
+
335
+ const previous = await c.findOne({
336
+ wid: entry.wid
337
+ }, {
338
+ sort: { seq: -1 },
339
+ projection: { seq: 1, _id: 0, sign: 1 }
340
+ });
341
+
342
+ Object.assign(entry, {
343
+ seq: previous ? previous.seq + 1 : 0
344
+ });
345
+
346
+ let insert;
347
+ if (secret) {
348
+ insert = this._objectToSign(entry);
349
+ const sign = this._signWithSecret(insert, secret, previous ? previous.sign : null);
350
+ Object.assign(insert, { sign });
351
+ } else {
352
+ insert = {
353
+ ...entry,
354
+ sign: null
355
+ };
356
+ }
357
+
358
+ // @ts-ignore
359
+ await c.insertOne(insert);
360
+ }
361
+
362
+ }
363
+
364
+ module.exports = AuditLogStorage;
@@ -4,17 +4,19 @@
4
4
  'use strict';
5
5
 
6
6
  const mongodb = require('mongodb'); // eslint-disable-line no-unused-vars
7
+ const crypto = require('crypto');
7
8
 
8
- /** @typedef {import('mongodb').IndexOptions} IndexOptions */
9
+ /** @typedef {import('mongodb/lib/db')} Db */
10
+ /** @typedef {import('mongodb/lib/collection')} Collection */
9
11
 
10
12
  class BaseStorage {
11
13
 
12
14
  /**
13
15
  *
14
- * @param {mongodb.Db|{():Promise<mongodb.Db>}} mongoDb
16
+ * @param {Db|{():Promise<Db>}} mongoDb
15
17
  * @param {string} collectionName
16
18
  * @param {{error:Function,log:Function}} [log] - console like logger
17
- * @param {boolean} isCosmo
19
+ * @param {boolean} [isCosmo]
18
20
  * @example
19
21
  *
20
22
  * const { BaseStorage } = require('winbot-mongodb');
@@ -47,18 +49,21 @@ class BaseStorage {
47
49
  this._log = log;
48
50
 
49
51
  /**
50
- * @type {Promise<mongodb.Collection>}
52
+ * @type {Collection|Promise<Collection>}
51
53
  */
52
54
  this._collection = null;
53
55
 
54
56
  this._indexes = [];
57
+
58
+ this.ignoredSignatureKeys = ['_id', 'sign'];
59
+ this._secret = null;
55
60
  }
56
61
 
57
62
  /**
58
63
  * Add custom indexing rule
59
64
  *
60
- * @param {Object} index
61
- * @param {IndexOptions} options
65
+ * @param {object} index
66
+ * @param {mongodb.IndexOptions} options
62
67
  */
63
68
  addIndex (index, options) {
64
69
  if (!options.name) {
@@ -81,7 +86,7 @@ class BaseStorage {
81
86
  const collections = await db.collections();
82
87
 
83
88
  collection = collections
84
- .find(c => c.collectionName === name);
89
+ .find((c) => c.collectionName === name);
85
90
 
86
91
  if (!collection) {
87
92
  try {
@@ -94,13 +99,16 @@ class BaseStorage {
94
99
  } else {
95
100
  collection = db.collection(name);
96
101
  }
102
+
103
+ await this._ensureIndexes(this._indexes, collection);
104
+
97
105
  return collection;
98
106
  }
99
107
 
100
108
  /**
101
109
  * Returns the collection to operate with
102
110
  *
103
- * @returns {Promise<mongodb.Collection>}
111
+ * @returns {Promise<Collection>}
104
112
  */
105
113
  async _getCollection () {
106
114
  if (this._collection === null) {
@@ -108,12 +116,11 @@ class BaseStorage {
108
116
  try {
109
117
  this._collection = this._getOrCreateCollection(this._collectionName);
110
118
  c = await this._collection;
119
+ this._collection = c;
111
120
  } catch (e) {
112
121
  this._collection = null;
113
122
  throw e;
114
123
  }
115
-
116
- await this._ensureIndexes(this._indexes, c);
117
124
  }
118
125
  return this._collection;
119
126
  }
@@ -126,34 +133,70 @@ class BaseStorage {
126
133
  existing = [];
127
134
  }
128
135
 
129
- await Promise.all(existing
130
- .filter(e => !['_id_', '_id'].includes(e.name)
131
- && !indexes.some(i => e.name === i.options.name))
132
- .map((e) => {
136
+ await existing
137
+ .filter((e) => !['_id_', '_id'].includes(e.name)
138
+ && !indexes.some((i) => e.name === i.options.name))
139
+ .reduce((p, e) => {
133
140
  // eslint-disable-next-line no-console
134
141
  this._log.log(`dropping index ${e.name}`);
135
- return collection.dropIndex(e.name)
142
+ return p
143
+ .then(() => collection.dropIndex(e.name))
136
144
  .catch((err) => {
137
145
  // eslint-disable-next-line no-console
138
146
  this._log.error(`dropping index ${e.name} FAILED`, err);
139
147
  });
140
- }));
141
-
142
- await Promise.all(indexes
143
- .filter(i => !existing.some(e => e.name === i.options.name))
144
- .map(i => collection
145
- .createIndex(i.index, i.options)
146
- // @ts-ignore
147
- .catch((e) => {
148
- if (i.isTextIndex) {
149
- this._doesNotSupportTextIndex = true;
150
- } else {
151
- this._log.error(`failed to create index ${i.options.name} on ${collection.collectionName}`);
152
- throw e;
153
- }
154
- })));
148
+ }, Promise.resolve());
149
+
150
+ await indexes
151
+ .filter((i) => !existing.some((e) => e.name === i.options.name))
152
+ .reduce((p, i) => {
153
+ this._log.log(`creating index ${i.name}`);
154
+ return p
155
+ .then(() => collection.createIndex(i.index, i.options))
156
+ .catch((e) => {
157
+ this._log.error(`failed to create index ${i.options.name} on ${collection.collectionName}`, e);
158
+ });
159
+ }, Promise.resolve());
160
+ }
161
+
162
+ async _sign (object) {
163
+ if (!this._secret) {
164
+ return object;
165
+ }
166
+ const secret = await Promise.resolve(this._secret);
167
+ const objToSign = this._objectToSign(object);
168
+ const sign = this._signWithSecret(objToSign, secret);
169
+
170
+ return Object.assign(objToSign, {
171
+ sign
172
+ });
155
173
  }
156
174
 
175
+ _objectToSign (object) {
176
+ const entries = Object.keys(object)
177
+ .filter((key) => !this.ignoredSignatureKeys.includes(key));
178
+
179
+ entries.sort();
180
+
181
+ return entries.reduce((o, key) => {
182
+ let val = object[key];
183
+ if (val instanceof Date) {
184
+ val = val.toISOString();
185
+ }
186
+ return Object.assign(o, { [key]: val });
187
+ }, {});
188
+ }
189
+
190
+ _signWithSecret (objToSign, secret, previous = null) {
191
+ const h = crypto.createHmac('sha3-224', secret)
192
+ .update(JSON.stringify(objToSign));
193
+
194
+ if (previous) {
195
+ h.update(previous);
196
+ }
197
+
198
+ return h.digest('base64');
199
+ }
157
200
  }
158
201
 
159
202
  module.exports = BaseStorage;
@@ -95,25 +95,36 @@ class BotConfigStorage {
95
95
  /**
96
96
  * @template T
97
97
  * @param {T} newConfig
98
+ * @param {string} [id]
98
99
  * @returns {Promise<T>}
99
100
  */
100
- async updateConfig (newConfig) {
101
+ async updateConfig (newConfig, id = CONFIG_ID) {
101
102
  Object.assign(newConfig, { timestamp: Date.now() });
102
103
 
103
- const c = await this._getCollection();
104
-
105
- await c.replaceOne({ _id: CONFIG_ID }, newConfig, { upsert: true });
104
+ await this.setConfig(id, newConfig);
106
105
 
107
106
  return newConfig;
108
107
  }
109
108
 
110
109
  /**
111
- * @returns {Promise<Object|null>}
110
+ *
111
+ * @param {string} id
112
+ * @param {object} newConfig
113
+ */
114
+ async setConfig (id, newConfig) {
115
+ const c = await this._getCollection();
116
+
117
+ await c.replaceOne({ _id: id }, newConfig, { upsert: true });
118
+ }
119
+
120
+ /**
121
+ * @param {string} [id]
122
+ * @returns {Promise<object | null>}
112
123
  */
113
- async getConfig () {
124
+ async getConfig (id = CONFIG_ID) {
114
125
  const c = await this._getCollection();
115
126
 
116
- return c.findOne({ _id: CONFIG_ID }, { projection: { _id: 0 } });
127
+ return c.findOne({ _id: id }, { projection: { _id: 0 } });
117
128
  }
118
129
 
119
130
  }
@@ -10,7 +10,7 @@ const TOKEN_INDEX = 'token-index';
10
10
  const USER_INDEX = 'user-page-index';
11
11
 
12
12
  /**
13
- * @typedef {Object} Token
13
+ * @typedef {object} Token
14
14
  * @prop {string} senderId
15
15
  * @prop {string} pageId
16
16
  * @prop {string} token
@@ -131,7 +131,7 @@ class BotTokenStorage {
131
131
  }
132
132
  }, {
133
133
  upsert: true,
134
- returnOriginal: false
134
+ returnDocument: 'after'
135
135
  });
136
136
 
137
137
  res = res.value;
@@ -166,7 +166,7 @@ class BotTokenStorage {
166
166
  }
167
167
 
168
168
  _wait (ms) {
169
- return new Promise(r => setTimeout(r, ms));
169
+ return new Promise((r) => setTimeout(r, ms));
170
170
  }
171
171
 
172
172
  }