emailengine-app 2.63.3 → 2.64.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.
- package/CHANGELOG.md +66 -0
- package/data/google-crawlers.json +1 -1
- package/eslint.config.js +2 -0
- package/lib/account.js +6 -2
- package/lib/consts.js +17 -1
- package/lib/email-client/gmail/gmail-api.js +1 -12
- package/lib/email-client/imap/mailbox.js +24 -6
- package/lib/email-client/imap/sync-operations.js +17 -5
- package/lib/email-client/imap-client.js +25 -16
- package/lib/email-client/outlook/graph-api.js +7 -13
- package/lib/email-client/outlook-client.js +363 -167
- package/lib/imapproxy/imap-server.js +1 -0
- package/lib/oauth/gmail.js +12 -1
- package/lib/oauth/pubsub/google.js +253 -85
- package/lib/oauth2-apps.js +554 -377
- package/lib/routes-ui.js +186 -91
- package/lib/schemas.js +18 -1
- package/lib/tools.js +6 -0
- package/lib/ui-routes/account-routes.js +1 -1
- package/lib/ui-routes/admin-entities-routes.js +3 -3
- package/lib/ui-routes/oauth-routes.js +9 -3
- package/package.json +13 -13
- package/sbom.json +1 -1
- package/server.js +54 -22
- package/static/licenses.html +39 -29
- package/translations/de.mo +0 -0
- package/translations/de.po +54 -42
- package/translations/en.mo +0 -0
- package/translations/en.po +55 -43
- package/translations/et.mo +0 -0
- package/translations/et.po +54 -42
- package/translations/fr.mo +0 -0
- package/translations/fr.po +54 -42
- package/translations/ja.mo +0 -0
- package/translations/ja.po +54 -42
- package/translations/messages.pot +74 -52
- package/translations/nl.mo +0 -0
- package/translations/nl.po +54 -42
- package/translations/pl.mo +0 -0
- package/translations/pl.po +54 -42
- package/views/config/oauth/app.hbs +12 -0
- package/views/config/oauth/index.hbs +2 -0
- package/views/config/oauth/subscriptions.hbs +175 -0
- package/views/error.hbs +4 -4
- package/views/partials/oauth_tabs.hbs +8 -0
- package/workers/api.js +174 -96
- package/workers/documents.js +1 -0
- package/workers/imap.js +30 -47
- package/workers/smtp.js +1 -0
- package/workers/submit.js +1 -0
- package/workers/webhooks.js +42 -30
package/lib/oauth2-apps.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { redis } = require('./db');
|
|
4
4
|
const msgpack = require('msgpack5')();
|
|
5
5
|
const logger = require('./logger');
|
|
6
|
-
const { REDIS_PREFIX } = require('./consts');
|
|
6
|
+
const { REDIS_PREFIX, TRANSIENT_NETWORK_CODES, GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL, GMAIL_PUBSUB_ACK_DEADLINE_SECONDS } = require('./consts');
|
|
7
7
|
const { encrypt, decrypt } = require('./encrypt');
|
|
8
8
|
const Boom = require('@hapi/boom');
|
|
9
9
|
const settings = require('./settings');
|
|
@@ -11,17 +11,6 @@ const Lock = require('ioredfour');
|
|
|
11
11
|
const getSecret = require('./get-secret');
|
|
12
12
|
const { parentPort } = require('worker_threads');
|
|
13
13
|
|
|
14
|
-
const TRANSIENT_NETWORK_CODES = new Set([
|
|
15
|
-
'ENOTFOUND',
|
|
16
|
-
'EAI_AGAIN',
|
|
17
|
-
'ETIMEDOUT',
|
|
18
|
-
'ECONNRESET',
|
|
19
|
-
'ECONNREFUSED',
|
|
20
|
-
'UND_ERR_SOCKET',
|
|
21
|
-
'UND_ERR_CONNECT_TIMEOUT',
|
|
22
|
-
'UND_ERR_HEADERS_TIMEOUT'
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
14
|
/**
|
|
26
15
|
* Record metrics for OAuth2 token operations
|
|
27
16
|
* Works in both main thread and worker threads
|
|
@@ -54,6 +43,9 @@ const { MailRuOauth, MAIL_RU_SCOPES } = require('./oauth/mail-ru');
|
|
|
54
43
|
const LEGACY_KEYS = ['gmail', 'gmailService', 'outlook', 'mailRu'];
|
|
55
44
|
const LEGACY_KEYS_REV = JSON.parse(JSON.stringify(LEGACY_KEYS)).reverse();
|
|
56
45
|
|
|
46
|
+
const PUBSUB_PERM_MANAGE = 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
|
|
47
|
+
const PUBSUB_PERM_VIEW = 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.';
|
|
48
|
+
|
|
57
49
|
const OAUTH_PROVIDERS = {
|
|
58
50
|
gmail: 'Gmail',
|
|
59
51
|
gmailService: 'Gmail Service Accounts',
|
|
@@ -176,11 +168,13 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
|
|
|
176
168
|
defaultScopes = (baseScopes && defaultScopesList[baseScopes]) || defaultScopesList.imap;
|
|
177
169
|
}
|
|
178
170
|
|
|
179
|
-
let extras = [];
|
|
180
171
|
if (!extraScopes && !skipScopes.length) {
|
|
181
172
|
return defaultScopes;
|
|
182
173
|
}
|
|
183
174
|
|
|
175
|
+
extraScopes = extraScopes || [];
|
|
176
|
+
|
|
177
|
+
let extras = [];
|
|
184
178
|
for (let extraScope of extraScopes) {
|
|
185
179
|
if (defaultScopes.includes(extraScope) || (scopePrefix && defaultScopes.includes(`${scopePrefix}/${extraScope}`))) {
|
|
186
180
|
// skip existing
|
|
@@ -192,19 +186,16 @@ function formatExtraScopes(extraScopes, baseScopes, defaultScopesList, skipScope
|
|
|
192
186
|
let result = extras.length ? extras.concat(defaultScopes) : defaultScopes;
|
|
193
187
|
|
|
194
188
|
if (skipScopes.length) {
|
|
195
|
-
result = result.filter(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
return true;
|
|
207
|
-
});
|
|
189
|
+
result = result.filter(
|
|
190
|
+
scope =>
|
|
191
|
+
!skipScopes.some(
|
|
192
|
+
skipScope =>
|
|
193
|
+
scope === skipScope ||
|
|
194
|
+
scope === `https://outlook.office.com/${skipScope}` ||
|
|
195
|
+
scope === `https://graph.microsoft.com/${skipScope}` ||
|
|
196
|
+
scope === `https://www.googleapis.com/auth/${skipScope}`
|
|
197
|
+
)
|
|
198
|
+
);
|
|
208
199
|
}
|
|
209
200
|
|
|
210
201
|
return result;
|
|
@@ -216,6 +207,9 @@ class OAuth2AppsHandler {
|
|
|
216
207
|
this.redis = this.options.redis;
|
|
217
208
|
|
|
218
209
|
this.secret = null;
|
|
210
|
+
|
|
211
|
+
this._pubSubBackfillDone = false;
|
|
212
|
+
this._pubSubBackfillPromise = null;
|
|
219
213
|
}
|
|
220
214
|
|
|
221
215
|
async encrypt(value) {
|
|
@@ -252,6 +246,56 @@ class OAuth2AppsHandler {
|
|
|
252
246
|
return `${REDIS_PREFIX}settings`;
|
|
253
247
|
}
|
|
254
248
|
|
|
249
|
+
async backfillPubSubApps() {
|
|
250
|
+
if (this._pubSubBackfillDone) {
|
|
251
|
+
return await this.redis.smembers(this.getSubscribersKey());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (this._pubSubBackfillPromise) {
|
|
255
|
+
return await this._pubSubBackfillPromise;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
this._pubSubBackfillPromise = this._doBackfillPubSubApps();
|
|
259
|
+
try {
|
|
260
|
+
return await this._pubSubBackfillPromise;
|
|
261
|
+
} finally {
|
|
262
|
+
this._pubSubBackfillPromise = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async _doBackfillPubSubApps() {
|
|
267
|
+
let [subscriberIds, allIds] = await Promise.all([this.redis.smembers(this.getSubscribersKey()), this.redis.smembers(this.getIndexKey())]);
|
|
268
|
+
let subscriberSet = new Set(subscriberIds);
|
|
269
|
+
let missingIds = allIds.filter(id => !subscriberSet.has(id));
|
|
270
|
+
if (!missingIds.length) {
|
|
271
|
+
this._pubSubBackfillDone = true;
|
|
272
|
+
return subscriberIds;
|
|
273
|
+
}
|
|
274
|
+
let bufKeys = missingIds.map(id => `${id}:data`);
|
|
275
|
+
let entries = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
|
|
276
|
+
let toAdd = [];
|
|
277
|
+
for (let i = 0; i < entries.length; i++) {
|
|
278
|
+
if (!entries[i]) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
let data = msgpack.decode(entries[i]);
|
|
283
|
+
if (data.baseScopes === 'pubsub') {
|
|
284
|
+
toAdd.push(missingIds[i]);
|
|
285
|
+
logger.info({ msg: 'Backfilled pubsub app to subscribers set', app: missingIds[i] });
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logger.warn({ msg: 'Failed to decode app data during backfill', app: missingIds[i], err });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (toAdd.length) {
|
|
292
|
+
await this.redis.sadd(this.getSubscribersKey(), ...toAdd);
|
|
293
|
+
subscriberIds.push(...toAdd);
|
|
294
|
+
}
|
|
295
|
+
this._pubSubBackfillDone = true;
|
|
296
|
+
return subscriberIds;
|
|
297
|
+
}
|
|
298
|
+
|
|
255
299
|
async list(page, pageSize, opts) {
|
|
256
300
|
opts = opts || {};
|
|
257
301
|
page = Math.max(Number(page) || 0, 0);
|
|
@@ -259,14 +303,12 @@ class OAuth2AppsHandler {
|
|
|
259
303
|
|
|
260
304
|
let startPos = page * pageSize;
|
|
261
305
|
|
|
262
|
-
let
|
|
306
|
+
let idList;
|
|
263
307
|
if (opts.pubsub) {
|
|
264
|
-
|
|
308
|
+
idList = await this.backfillPubSubApps();
|
|
265
309
|
} else {
|
|
266
|
-
|
|
310
|
+
idList = await this.redis.smembers(this.getIndexKey());
|
|
267
311
|
}
|
|
268
|
-
|
|
269
|
-
let idList = await this.redis.smembers(this[keyFunc]());
|
|
270
312
|
idList = [].concat(idList || []).sort((a, b) => -a.localeCompare(b));
|
|
271
313
|
|
|
272
314
|
if (!opts.pubsub) {
|
|
@@ -303,7 +345,7 @@ class OAuth2AppsHandler {
|
|
|
303
345
|
let data = await this.get(legacyKey);
|
|
304
346
|
response.apps.push(data);
|
|
305
347
|
} catch (err) {
|
|
306
|
-
logger.error({ msg: 'Failed to process legacy app', legacyKey });
|
|
348
|
+
logger.error({ msg: 'Failed to process legacy app', legacyKey, err });
|
|
307
349
|
continue;
|
|
308
350
|
}
|
|
309
351
|
}
|
|
@@ -311,18 +353,21 @@ class OAuth2AppsHandler {
|
|
|
311
353
|
if (keys.length) {
|
|
312
354
|
let bufKeys = keys.flatMap(id => [`${id}:data`, `${id}:meta`]);
|
|
313
355
|
let list = await this.redis.hmgetBuffer(this.getDataKey(), bufKeys);
|
|
314
|
-
for (let i = 0; i < list.length; i
|
|
315
|
-
let
|
|
356
|
+
for (let i = 0; i < list.length; i += 2) {
|
|
357
|
+
let dataEntry = list[i];
|
|
358
|
+
let metaEntry = list[i + 1];
|
|
316
359
|
try {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
360
|
+
let data = msgpack.decode(dataEntry);
|
|
361
|
+
if (metaEntry) {
|
|
362
|
+
try {
|
|
363
|
+
data.meta = msgpack.decode(metaEntry);
|
|
364
|
+
} catch (metaErr) {
|
|
365
|
+
logger.error({ msg: 'Failed to process app meta', entryLength: metaEntry?.length || 0 });
|
|
366
|
+
}
|
|
322
367
|
}
|
|
368
|
+
response.apps.push(data);
|
|
323
369
|
} catch (err) {
|
|
324
|
-
logger.error({ msg: 'Failed to process app',
|
|
325
|
-
continue;
|
|
370
|
+
logger.error({ msg: 'Failed to process app', entryLength: dataEntry?.length || 0 });
|
|
326
371
|
}
|
|
327
372
|
}
|
|
328
373
|
}
|
|
@@ -370,13 +415,11 @@ class OAuth2AppsHandler {
|
|
|
370
415
|
async generateId() {
|
|
371
416
|
let idNum = await this.redis.hincrby(this.getSettingsKey(), 'idcount', 1);
|
|
372
417
|
|
|
373
|
-
let idBuf = Buffer.alloc(
|
|
418
|
+
let idBuf = Buffer.alloc(12);
|
|
374
419
|
idBuf.writeBigUInt64BE(BigInt(Date.now()), 0);
|
|
375
420
|
idBuf.writeUInt32BE(idNum, 8);
|
|
376
421
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return id;
|
|
422
|
+
return idBuf.toString('base64url');
|
|
380
423
|
}
|
|
381
424
|
|
|
382
425
|
async getLegacyApp(id) {
|
|
@@ -589,15 +632,15 @@ class OAuth2AppsHandler {
|
|
|
589
632
|
try {
|
|
590
633
|
data = msgpack.decode(getDataBuf);
|
|
591
634
|
} catch (err) {
|
|
592
|
-
logger.error({ msg: 'Failed to process app', app: id,
|
|
635
|
+
logger.error({ msg: 'Failed to process app', app: id, entryLength: getDataBuf?.length || 0 });
|
|
593
636
|
throw err;
|
|
594
637
|
}
|
|
595
638
|
|
|
596
639
|
if (getMetaBuf) {
|
|
597
640
|
try {
|
|
598
|
-
data =
|
|
641
|
+
data.meta = msgpack.decode(getMetaBuf);
|
|
599
642
|
} catch (err) {
|
|
600
|
-
logger.error({ msg: 'Failed to process app', app: id,
|
|
643
|
+
logger.error({ msg: 'Failed to process app meta', app: id, entryLength: getMetaBuf?.length || 0 });
|
|
601
644
|
}
|
|
602
645
|
}
|
|
603
646
|
|
|
@@ -635,7 +678,7 @@ class OAuth2AppsHandler {
|
|
|
635
678
|
[`${id}:data`]: msgpack.encode(entry)
|
|
636
679
|
});
|
|
637
680
|
|
|
638
|
-
if (data.pubSubSubscription) {
|
|
681
|
+
if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
|
|
639
682
|
insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
|
|
640
683
|
}
|
|
641
684
|
|
|
@@ -645,9 +688,9 @@ class OAuth2AppsHandler {
|
|
|
645
688
|
|
|
646
689
|
let insertResult = await insertResultReq.exec();
|
|
647
690
|
|
|
648
|
-
let
|
|
649
|
-
if (
|
|
650
|
-
throw
|
|
691
|
+
let errorEntry = insertResult.find(entry => entry && entry[0]);
|
|
692
|
+
if (errorEntry) {
|
|
693
|
+
throw errorEntry[0];
|
|
651
694
|
}
|
|
652
695
|
|
|
653
696
|
const result = {
|
|
@@ -655,17 +698,7 @@ class OAuth2AppsHandler {
|
|
|
655
698
|
created: true
|
|
656
699
|
};
|
|
657
700
|
|
|
658
|
-
|
|
659
|
-
let appData = await this.get(id);
|
|
660
|
-
if (appData.baseScopes === 'pubsub') {
|
|
661
|
-
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
662
|
-
if (Object.keys(pubsubUpdates || {})) {
|
|
663
|
-
result.pubsubUpdates = pubsubUpdates;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
} catch (err) {
|
|
667
|
-
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
668
|
-
}
|
|
701
|
+
await this.tryEnsurePubsub(id, result);
|
|
669
702
|
|
|
670
703
|
return result;
|
|
671
704
|
}
|
|
@@ -687,6 +720,8 @@ class OAuth2AppsHandler {
|
|
|
687
720
|
|
|
688
721
|
let existingData = msgpack.decode(existingDataBuf);
|
|
689
722
|
|
|
723
|
+
let oldPubSubApp = existingData.pubSubApp || null;
|
|
724
|
+
|
|
690
725
|
let encryptedValues = {};
|
|
691
726
|
for (let key of ['clientSecret', 'serviceKey', 'accessToken']) {
|
|
692
727
|
if (data[key]) {
|
|
@@ -706,22 +741,25 @@ class OAuth2AppsHandler {
|
|
|
706
741
|
|
|
707
742
|
let insertResultReq = this.redis.multi().sadd(this.getIndexKey(), id).hmset(this.getDataKey(), updates);
|
|
708
743
|
|
|
709
|
-
if (data.pubSubSubscription) {
|
|
744
|
+
if (data.pubSubSubscription || entry.baseScopes === 'pubsub') {
|
|
710
745
|
insertResultReq = insertResultReq.sadd(this.getSubscribersKey(), id);
|
|
711
746
|
}
|
|
712
747
|
|
|
713
748
|
if (data.pubSubApp) {
|
|
714
|
-
if (
|
|
715
|
-
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(
|
|
749
|
+
if (oldPubSubApp && oldPubSubApp !== data.pubSubApp) {
|
|
750
|
+
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
|
|
716
751
|
}
|
|
717
752
|
insertResultReq = insertResultReq.sadd(this.getPubsubAppKey(data.pubSubApp), id);
|
|
753
|
+
} else if (oldPubSubApp && 'pubSubApp' in data) {
|
|
754
|
+
// pubSubApp was explicitly cleared
|
|
755
|
+
insertResultReq = insertResultReq.srem(this.getPubsubAppKey(oldPubSubApp), id);
|
|
718
756
|
}
|
|
719
757
|
|
|
720
758
|
let insertResult = await insertResultReq.exec();
|
|
721
759
|
|
|
722
|
-
let
|
|
723
|
-
if (
|
|
724
|
-
throw
|
|
760
|
+
let errorEntry = insertResult.find(entry => entry && entry[0]);
|
|
761
|
+
if (errorEntry) {
|
|
762
|
+
throw errorEntry[0];
|
|
725
763
|
}
|
|
726
764
|
|
|
727
765
|
if (opts.partial) {
|
|
@@ -739,17 +777,7 @@ class OAuth2AppsHandler {
|
|
|
739
777
|
updated: true
|
|
740
778
|
};
|
|
741
779
|
|
|
742
|
-
|
|
743
|
-
let appData = await this.get(id);
|
|
744
|
-
if (appData.baseScopes === 'pubsub') {
|
|
745
|
-
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
746
|
-
if (Object.keys(pubsubUpdates || {})) {
|
|
747
|
-
result.pubsubUpdates = pubsubUpdates;
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
} catch (err) {
|
|
751
|
-
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
752
|
-
}
|
|
780
|
+
await this.tryEnsurePubsub(id, result);
|
|
753
781
|
|
|
754
782
|
return result;
|
|
755
783
|
}
|
|
@@ -762,148 +790,224 @@ class OAuth2AppsHandler {
|
|
|
762
790
|
|
|
763
791
|
let appData = await this.get(id);
|
|
764
792
|
|
|
765
|
-
if (appData
|
|
766
|
-
|
|
793
|
+
if (!appData) {
|
|
794
|
+
return { id, deleted: false, accounts: 0 };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Acquire the same lock used by ensurePubsub to prevent racing with recovery
|
|
798
|
+
let needsPubSubLock = appData.pubSubTopic || appData.baseScopes === 'pubsub';
|
|
799
|
+
let delLock;
|
|
800
|
+
|
|
801
|
+
if (needsPubSubLock) {
|
|
802
|
+
let lockKey = ['oauth', 'pubsub', appData.id].join(':');
|
|
767
803
|
try {
|
|
768
|
-
await
|
|
804
|
+
delLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
|
|
805
|
+
if (!delLock.success) {
|
|
806
|
+
throw new Error('Failed to get lock for pubsub app deletion');
|
|
807
|
+
}
|
|
769
808
|
} catch (err) {
|
|
770
|
-
logger.error({ msg: 'Failed to
|
|
809
|
+
logger.error({ msg: 'Failed to acquire lock for pubsub app deletion', lockKey, err });
|
|
810
|
+
throw err;
|
|
771
811
|
}
|
|
772
812
|
}
|
|
773
813
|
|
|
774
|
-
|
|
775
|
-
.
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
814
|
+
try {
|
|
815
|
+
if (appData.pubSubTopic) {
|
|
816
|
+
// try to delete topic
|
|
817
|
+
try {
|
|
818
|
+
await this.deleteTopic(appData);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
logger.error({
|
|
821
|
+
msg: 'Failed to delete existing pubsub topic',
|
|
822
|
+
app: appData.id,
|
|
823
|
+
topic: appData.pubSubTopic,
|
|
824
|
+
subscription: appData.pubSubSubscription,
|
|
825
|
+
err
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
786
829
|
|
|
787
|
-
|
|
830
|
+
let pipeline = this.redis
|
|
831
|
+
.multi()
|
|
832
|
+
.srem(this.getIndexKey(), id)
|
|
833
|
+
.hdel(this.getDataKey(), [`${id}:data`, `${id}:meta`])
|
|
834
|
+
.scard(`${REDIS_PREFIX}oapp:a:${id}`)
|
|
835
|
+
.del(`${REDIS_PREFIX}oapp:a:${id}`)
|
|
836
|
+
.del(`${REDIS_PREFIX}oapp:h:${id}`)
|
|
837
|
+
.srem(this.getSubscribersKey(), id);
|
|
838
|
+
|
|
839
|
+
if (appData.pubSubApp) {
|
|
840
|
+
pipeline = pipeline.srem(this.getPubsubAppKey(appData.pubSubApp), id);
|
|
841
|
+
}
|
|
788
842
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
}
|
|
843
|
+
if (appData.baseScopes === 'pubsub') {
|
|
844
|
+
pipeline = pipeline.del(this.getPubsubAppKey(id));
|
|
845
|
+
}
|
|
793
846
|
|
|
794
|
-
|
|
847
|
+
let deleteResult = await pipeline.exec();
|
|
795
848
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
849
|
+
let errorEntry = deleteResult.find(entry => entry && entry[0]);
|
|
850
|
+
if (errorEntry) {
|
|
851
|
+
throw errorEntry[0];
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let deletedDocs = ((deleteResult[0] && deleteResult[0][1]) || 0) + ((deleteResult[1] && deleteResult[1][1]) || 0);
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
id,
|
|
858
|
+
deleted: deletedDocs >= 2,
|
|
859
|
+
accounts: Number(deleteResult[2] && deleteResult[2][1]) || 0
|
|
860
|
+
};
|
|
861
|
+
} finally {
|
|
862
|
+
if (delLock?.success) {
|
|
863
|
+
await lock.releaseLock(delLock);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
801
866
|
}
|
|
802
867
|
|
|
868
|
+
// Note: not atomic (read-modify-write). Concurrent callers use last-write-wins,
|
|
869
|
+
// which is acceptable for self-correcting informational metadata.
|
|
803
870
|
async setMeta(id, meta) {
|
|
804
|
-
let existingMeta;
|
|
805
871
|
let existingMetaBuf = await this.redis.hgetBuffer(this.getDataKey(), `${id}:meta`);
|
|
806
|
-
|
|
807
|
-
existingMeta = {};
|
|
808
|
-
} else {
|
|
809
|
-
existingMeta = msgpack.decode(existingMetaBuf);
|
|
810
|
-
}
|
|
872
|
+
let existingMeta = existingMetaBuf ? msgpack.decode(existingMetaBuf) : {};
|
|
811
873
|
|
|
812
874
|
let entry = Object.assign(existingMeta, meta || {});
|
|
813
875
|
|
|
814
|
-
|
|
876
|
+
await this.redis.hmset(this.getDataKey(), {
|
|
815
877
|
[`${id}:meta`]: msgpack.encode(entry)
|
|
816
|
-
};
|
|
817
|
-
|
|
818
|
-
await this.redis.hmset(this.getDataKey(), updates);
|
|
878
|
+
});
|
|
819
879
|
|
|
820
|
-
return {
|
|
821
|
-
id,
|
|
822
|
-
updated: true
|
|
823
|
-
};
|
|
880
|
+
return { id, updated: true };
|
|
824
881
|
}
|
|
825
882
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
837
|
-
if (!accessToken) {
|
|
838
|
-
throw new Error('Failed to get access token');
|
|
839
|
-
}
|
|
883
|
+
/**
|
|
884
|
+
* Fire-and-forget setMeta call that logs errors instead of throwing.
|
|
885
|
+
* Used in ensurePubsub error handlers where we want to record a flag
|
|
886
|
+
* but must not block or alter the original error flow.
|
|
887
|
+
*/
|
|
888
|
+
setMetaFireAndForget(appId, meta) {
|
|
889
|
+
this.setMeta(appId, meta).catch(metaErr => {
|
|
890
|
+
logger.error({ msg: 'Failed to set metadata', app: appId, err: metaErr });
|
|
891
|
+
});
|
|
892
|
+
}
|
|
840
893
|
|
|
894
|
+
/**
|
|
895
|
+
* Try to set up Pub/Sub for the given app and attach results to the result object.
|
|
896
|
+
* Errors are logged but not thrown so they do not prevent create/update from succeeding.
|
|
897
|
+
*/
|
|
898
|
+
async tryEnsurePubsub(id, result) {
|
|
841
899
|
try {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
{}
|
|
847
|
-
*/
|
|
900
|
+
let appData = await this.get(id);
|
|
901
|
+
if (appData.baseScopes === 'pubsub') {
|
|
902
|
+
let pubsubUpdates = await this.ensurePubsub(appData);
|
|
903
|
+
result.pubsubUpdates = pubsubUpdates || {};
|
|
848
904
|
}
|
|
849
905
|
} catch (err) {
|
|
850
|
-
|
|
851
|
-
case 403:
|
|
852
|
-
// no permissions
|
|
853
|
-
logger.error({
|
|
854
|
-
msg: 'Service client does not have permissions to delete Pub/Sub topics. Make sure the role for the service user is "Pub/Sub Admin".',
|
|
855
|
-
app: appData.id,
|
|
856
|
-
topic: topicName
|
|
857
|
-
});
|
|
858
|
-
throw err;
|
|
859
|
-
case 404: {
|
|
860
|
-
// does not exist
|
|
861
|
-
logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
|
|
862
|
-
break;
|
|
863
|
-
}
|
|
864
|
-
default:
|
|
865
|
-
throw err;
|
|
866
|
-
}
|
|
906
|
+
logger.error({ msg: 'Failed to set up pubsub', app: id, err });
|
|
867
907
|
}
|
|
908
|
+
}
|
|
868
909
|
|
|
910
|
+
async _retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, reason) {
|
|
911
|
+
logger.warn({
|
|
912
|
+
msg: `${reason} deleting Pub/Sub ${resourceType}, retrying in ${delay}ms`,
|
|
913
|
+
app: appData.id,
|
|
914
|
+
[resourceType]: resourceName
|
|
915
|
+
});
|
|
916
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
869
917
|
try {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
918
|
+
await doDelete();
|
|
919
|
+
} catch (retryErr) {
|
|
920
|
+
if (retryErr?.oauthRequest?.response?.error?.code === 404) {
|
|
921
|
+
logger.info({
|
|
922
|
+
msg: `${resourceType} no longer exists after retry`,
|
|
923
|
+
app: appData.id,
|
|
924
|
+
[resourceType]: resourceName
|
|
925
|
+
});
|
|
926
|
+
return;
|
|
876
927
|
}
|
|
928
|
+
logger.error({
|
|
929
|
+
msg: `Retry failed deleting Pub/Sub ${resourceType}, resource may need manual cleanup`,
|
|
930
|
+
app: appData.id,
|
|
931
|
+
[resourceType]: resourceName,
|
|
932
|
+
code: retryErr.code || retryErr?.oauthRequest?.response?.error?.code
|
|
933
|
+
});
|
|
934
|
+
throw retryErr;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async _deletePubSubResource(client, accessToken, appData, resourceType, resourceName) {
|
|
939
|
+
if (!resourceName) {
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
let url = `https://pubsub.googleapis.com/v1/${resourceName}`;
|
|
943
|
+
const doDelete = () => client.request(accessToken, url, 'DELETE', Buffer.alloc(0), { returnText: true });
|
|
944
|
+
try {
|
|
945
|
+
await doDelete();
|
|
877
946
|
} catch (err) {
|
|
878
947
|
switch (err?.oauthRequest?.response?.error?.code) {
|
|
879
948
|
case 403:
|
|
880
|
-
// no permissions
|
|
881
949
|
logger.error({
|
|
882
|
-
msg:
|
|
950
|
+
msg: `Service client does not have permissions to delete Pub/Sub ${resourceType}s. Make sure the role for the service user is "Pub/Sub Admin".`,
|
|
883
951
|
app: appData.id,
|
|
884
|
-
|
|
952
|
+
[resourceType]: resourceName
|
|
885
953
|
});
|
|
886
954
|
throw err;
|
|
887
|
-
case 404:
|
|
888
|
-
|
|
889
|
-
|
|
955
|
+
case 404:
|
|
956
|
+
logger.info({ msg: `${resourceType} does not exist`, app: appData.id, [resourceType]: resourceName });
|
|
957
|
+
break;
|
|
958
|
+
case 429: {
|
|
959
|
+
let retryAfter = err.retryAfter || err.oauthRequest?.retryAfter;
|
|
960
|
+
let delay = retryAfter ? Math.min(retryAfter * 1000, 30000) : 2000;
|
|
961
|
+
await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, delay, 'Rate limited');
|
|
890
962
|
break;
|
|
891
963
|
}
|
|
892
964
|
default:
|
|
965
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
966
|
+
await this._retryDeleteOnce(doDelete, appData, resourceType, resourceName, 2000, 'Transient network error');
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
893
969
|
throw err;
|
|
894
970
|
}
|
|
895
971
|
}
|
|
896
972
|
}
|
|
897
973
|
|
|
974
|
+
async deleteTopic(appData) {
|
|
975
|
+
let client = await this.getClient(appData.id);
|
|
976
|
+
|
|
977
|
+
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
978
|
+
if (!accessToken) {
|
|
979
|
+
throw new Error('Failed to get access token');
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
await this._deletePubSubResource(client, accessToken, appData, 'subscription', appData.pubSubSubscription);
|
|
983
|
+
await this._deletePubSubResource(client, accessToken, appData, 'topic', appData.pubSubTopic);
|
|
984
|
+
}
|
|
985
|
+
|
|
898
986
|
async ensurePubsub(appData) {
|
|
899
987
|
let project = appData.googleProjectId;
|
|
900
988
|
|
|
901
989
|
let topic = appData.googleTopicName || `ee-pub-${appData.id}`;
|
|
902
990
|
let subscription = appData.googleSubscriptionName || `ee-sub-${appData.id}`;
|
|
903
991
|
|
|
992
|
+
// Determine desired expirationPolicy from setting
|
|
993
|
+
let gmailSubscriptionTtl = await settings.get('gmailSubscriptionTtl');
|
|
994
|
+
let desiredExpirationPolicy;
|
|
995
|
+
if (gmailSubscriptionTtl === 0) {
|
|
996
|
+
// Indefinite (no expiration)
|
|
997
|
+
desiredExpirationPolicy = {};
|
|
998
|
+
} else if (typeof gmailSubscriptionTtl === 'number' && gmailSubscriptionTtl > 0) {
|
|
999
|
+
// Convert days to seconds for Google API
|
|
1000
|
+
desiredExpirationPolicy = { ttl: `${gmailSubscriptionTtl * 86400}s` };
|
|
1001
|
+
} else {
|
|
1002
|
+
// Not set - let Google apply its default 31-day TTL
|
|
1003
|
+
desiredExpirationPolicy = null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
904
1006
|
let results = {};
|
|
1007
|
+
let ttlPatchFailed = null;
|
|
905
1008
|
|
|
906
|
-
if (!project
|
|
1009
|
+
if (!project) {
|
|
1010
|
+
logger.warn({ msg: 'googleProjectId is required for Pub/Sub setup', app: appData.id });
|
|
907
1011
|
return results;
|
|
908
1012
|
}
|
|
909
1013
|
|
|
@@ -916,130 +1020,181 @@ class OAuth2AppsHandler {
|
|
|
916
1020
|
const member = 'serviceAccount:gmail-api-push@system.gserviceaccount.com';
|
|
917
1021
|
const role = 'roles/pubsub.publisher';
|
|
918
1022
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1023
|
+
// Acquire per-app lock to prevent concurrent ensurePubsub races (e.g., user update + background recovery)
|
|
1024
|
+
let lockKey = ['oauth', 'pubsub', appData.id].join(':');
|
|
1025
|
+
let ensureLock;
|
|
1026
|
+
try {
|
|
1027
|
+
ensureLock = await lock.waitAcquireLock(lockKey, 5 * 60 * 1000, 2 * 60 * 1000);
|
|
1028
|
+
if (!ensureLock.success) {
|
|
1029
|
+
logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey });
|
|
1030
|
+
throw new Error('Failed to get ensurePubsub lock');
|
|
1031
|
+
}
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
logger.error({ msg: 'Failed to get ensurePubsub lock', lockKey, err });
|
|
1034
|
+
throw err;
|
|
925
1035
|
}
|
|
926
1036
|
|
|
927
|
-
// Step 2. Ensure topic
|
|
928
1037
|
try {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
} catch (err) {
|
|
936
|
-
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
937
|
-
logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
|
|
938
|
-
throw err;
|
|
1038
|
+
let client = await this.getClient(appData.id);
|
|
1039
|
+
|
|
1040
|
+
// Step 1. Get access token for service client
|
|
1041
|
+
let accessToken = await this.getServiceAccessToken(appData, client);
|
|
1042
|
+
if (!accessToken) {
|
|
1043
|
+
throw new Error('Failed to get access token');
|
|
939
1044
|
}
|
|
940
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
941
|
-
case 403:
|
|
942
|
-
// no permissions
|
|
943
|
-
if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
|
|
944
|
-
this.setMeta(appData.id, {
|
|
945
|
-
authFlag: {
|
|
946
|
-
message:
|
|
947
|
-
'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
|
|
948
|
-
description: err?.oauthRequest?.response?.error?.message
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
} else {
|
|
952
|
-
this.setMeta(appData.id, {
|
|
953
|
-
authFlag: {
|
|
954
|
-
message: 'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
955
|
-
}
|
|
956
|
-
});
|
|
957
|
-
}
|
|
958
1045
|
|
|
1046
|
+
// Step 2. Ensure topic
|
|
1047
|
+
try {
|
|
1048
|
+
// fails if topic does not exist
|
|
1049
|
+
await client.request(accessToken, topicUrl, 'GET');
|
|
1050
|
+
logger.debug({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
1051
|
+
/*
|
|
1052
|
+
{name: 'projects/...'}
|
|
1053
|
+
*/
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1056
|
+
logger.warn({ msg: 'Network error checking Pub/Sub topic', app: appData.id, code: err.code });
|
|
959
1057
|
throw err;
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1058
|
+
}
|
|
1059
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1060
|
+
case 403:
|
|
1061
|
+
// no permissions
|
|
1062
|
+
if (/Cloud Pub\/Sub API has not been used in project/.test(err?.oauthRequest?.response?.error?.message)) {
|
|
1063
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1064
|
+
authFlag: {
|
|
1065
|
+
message:
|
|
1066
|
+
'Enable the Cloud Pub/Sub API for your project before using the service client. Check the server response below for details.',
|
|
1067
|
+
description: err?.oauthRequest?.response?.error?.message
|
|
1068
|
+
},
|
|
1069
|
+
ttlWarning: null
|
|
1070
|
+
});
|
|
1071
|
+
} else {
|
|
1072
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1073
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1074
|
+
ttlWarning: null
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
throw err;
|
|
1079
|
+
case 404: {
|
|
1080
|
+
// does not exist
|
|
1081
|
+
logger.info({ msg: 'Topic does not exist', app: appData.id, topic: topicName });
|
|
1082
|
+
try {
|
|
1083
|
+
let topicCreateRes = await client.request(accessToken, topicUrl, 'PUT', Buffer.alloc(0));
|
|
1084
|
+
/*
|
|
966
1085
|
{name: 'projects/...'}
|
|
967
1086
|
*/
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1087
|
+
if (!topicCreateRes?.name) {
|
|
1088
|
+
throw new Error('Topic was not created', { cause: err });
|
|
1089
|
+
}
|
|
971
1090
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
throw err;
|
|
985
|
-
}
|
|
986
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
987
|
-
case 403:
|
|
988
|
-
// no permissions
|
|
989
|
-
this.setMeta(appData.id, {
|
|
990
|
-
authFlag: {
|
|
991
|
-
message:
|
|
992
|
-
'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
993
|
-
}
|
|
994
|
-
});
|
|
995
|
-
throw err;
|
|
996
|
-
case 409:
|
|
997
|
-
// already exists
|
|
998
|
-
logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
999
|
-
break;
|
|
1000
|
-
default:
|
|
1091
|
+
await this.update(
|
|
1092
|
+
appData.id,
|
|
1093
|
+
{
|
|
1094
|
+
pubSubTopic: topicName
|
|
1095
|
+
},
|
|
1096
|
+
{ partial: true }
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
results.pubSubTopic = topicName;
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1102
|
+
logger.warn({ msg: 'Network error creating Pub/Sub topic', app: appData.id, code: err.code });
|
|
1001
1103
|
throw err;
|
|
1104
|
+
}
|
|
1105
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1106
|
+
case 403:
|
|
1107
|
+
// no permissions
|
|
1108
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1109
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1110
|
+
ttlWarning: null
|
|
1111
|
+
});
|
|
1112
|
+
throw err;
|
|
1113
|
+
case 409:
|
|
1114
|
+
// already exists
|
|
1115
|
+
logger.info({ msg: 'Topic already exists', app: appData.id, topic: topicName });
|
|
1116
|
+
break;
|
|
1117
|
+
default:
|
|
1118
|
+
throw err;
|
|
1119
|
+
}
|
|
1002
1120
|
}
|
|
1121
|
+
break;
|
|
1003
1122
|
}
|
|
1004
|
-
|
|
1123
|
+
default:
|
|
1124
|
+
throw err;
|
|
1005
1125
|
}
|
|
1006
|
-
default:
|
|
1007
|
-
throw err;
|
|
1008
1126
|
}
|
|
1009
|
-
}
|
|
1010
1127
|
|
|
1011
|
-
|
|
1128
|
+
// Step 3. Set up subscriber
|
|
1012
1129
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1130
|
+
try {
|
|
1131
|
+
// fails if topic does not exist
|
|
1132
|
+
let subscriptionData = await client.request(accessToken, subscriptionUrl, 'GET');
|
|
1133
|
+
logger.debug({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1134
|
+
/*
|
|
1018
1135
|
{name: 'projects/...'}
|
|
1019
1136
|
*/
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
// no permissions
|
|
1028
|
-
this.setMeta(appData.id, {
|
|
1029
|
-
authFlag: {
|
|
1030
|
-
message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1031
|
-
}
|
|
1032
|
-
});
|
|
1033
|
-
throw err;
|
|
1034
|
-
case 404: {
|
|
1035
|
-
// does not exist
|
|
1036
|
-
logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
|
|
1137
|
+
|
|
1138
|
+
// Patch existing subscription's expiration policy if it differs from desired
|
|
1139
|
+
// When no explicit TTL is configured, use Google's default (31 days) for comparison
|
|
1140
|
+
let effectivePolicy = desiredExpirationPolicy ?? { ttl: GMAIL_PUBSUB_DEFAULT_EXPIRATION_TTL };
|
|
1141
|
+
let currentTtl = subscriptionData?.expirationPolicy?.ttl || null;
|
|
1142
|
+
let desiredTtl = effectivePolicy.ttl || null;
|
|
1143
|
+
if (currentTtl !== desiredTtl) {
|
|
1037
1144
|
try {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1145
|
+
await client.request(accessToken, subscriptionUrl, 'PATCH', {
|
|
1146
|
+
subscription: { expirationPolicy: effectivePolicy },
|
|
1147
|
+
updateMask: 'expirationPolicy'
|
|
1041
1148
|
});
|
|
1042
|
-
|
|
1149
|
+
logger.info({
|
|
1150
|
+
msg: 'Updated expiration policy on subscription',
|
|
1151
|
+
app: appData.id,
|
|
1152
|
+
subscription: subscriptionName,
|
|
1153
|
+
desiredTtl
|
|
1154
|
+
});
|
|
1155
|
+
// Update stored TTL after successful patch
|
|
1156
|
+
subscriptionData.expirationPolicy = effectivePolicy;
|
|
1157
|
+
} catch (patchErr) {
|
|
1158
|
+
ttlPatchFailed = patchErr;
|
|
1159
|
+
logger.warn({
|
|
1160
|
+
msg: 'Failed to update expiration policy on subscription',
|
|
1161
|
+
app: appData.id,
|
|
1162
|
+
subscription: subscriptionName,
|
|
1163
|
+
err: patchErr
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Store current expiration info in meta
|
|
1169
|
+
await this.setMeta(appData.id, {
|
|
1170
|
+
subscriptionExpiration: subscriptionData?.expirationPolicy?.ttl || null
|
|
1171
|
+
});
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1174
|
+
logger.warn({ msg: 'Network error checking Pub/Sub subscription', app: appData.id, code: err.code });
|
|
1175
|
+
throw err;
|
|
1176
|
+
}
|
|
1177
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1178
|
+
case 403:
|
|
1179
|
+
// no permissions
|
|
1180
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1181
|
+
authFlag: { message: PUBSUB_PERM_VIEW },
|
|
1182
|
+
ttlWarning: null
|
|
1183
|
+
});
|
|
1184
|
+
throw err;
|
|
1185
|
+
case 404: {
|
|
1186
|
+
// does not exist
|
|
1187
|
+
logger.info({ msg: 'Subscription does not exist', app: appData.id, subscription: subscriptionName });
|
|
1188
|
+
try {
|
|
1189
|
+
let createPayload = {
|
|
1190
|
+
topic: topicName,
|
|
1191
|
+
ackDeadlineSeconds: GMAIL_PUBSUB_ACK_DEADLINE_SECONDS
|
|
1192
|
+
};
|
|
1193
|
+
if (desiredExpirationPolicy !== null) {
|
|
1194
|
+
createPayload.expirationPolicy = desiredExpirationPolicy;
|
|
1195
|
+
}
|
|
1196
|
+
let subscriptionCreateRes = await client.request(accessToken, subscriptionUrl, 'PUT', createPayload);
|
|
1197
|
+
/*
|
|
1043
1198
|
{
|
|
1044
1199
|
name: 'projects/webhooks-425411/subscriptions/ee-sub-AAABkE9_uNMAAAAH',
|
|
1045
1200
|
topic: 'projects/webhooks-425411/topics/ee-pub-AAABkE9_uNMAAAAH',
|
|
@@ -1051,57 +1206,61 @@ class OAuth2AppsHandler {
|
|
|
1051
1206
|
}
|
|
1052
1207
|
*/
|
|
1053
1208
|
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1209
|
+
if (!subscriptionCreateRes?.name) {
|
|
1210
|
+
throw new Error('Subscription was not created', { cause: err });
|
|
1211
|
+
}
|
|
1057
1212
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
this.setMeta(appData.id, {
|
|
1076
|
-
authFlag: {
|
|
1077
|
-
message:
|
|
1078
|
-
'Service client does not have permission to manage Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1079
|
-
}
|
|
1080
|
-
});
|
|
1081
|
-
throw err;
|
|
1082
|
-
case 409:
|
|
1083
|
-
// already exists
|
|
1084
|
-
logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1085
|
-
break;
|
|
1086
|
-
default:
|
|
1213
|
+
await this.update(
|
|
1214
|
+
appData.id,
|
|
1215
|
+
{
|
|
1216
|
+
pubSubSubscription: subscriptionName
|
|
1217
|
+
},
|
|
1218
|
+
{ partial: true }
|
|
1219
|
+
);
|
|
1220
|
+
|
|
1221
|
+
results.pubSubSubscription = subscriptionName;
|
|
1222
|
+
|
|
1223
|
+
// Store expiration info from the created subscription
|
|
1224
|
+
await this.setMeta(appData.id, {
|
|
1225
|
+
subscriptionExpiration: subscriptionCreateRes?.expirationPolicy?.ttl || null
|
|
1226
|
+
});
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1229
|
+
logger.warn({ msg: 'Network error creating Pub/Sub subscription', app: appData.id, code: err.code });
|
|
1087
1230
|
throw err;
|
|
1231
|
+
}
|
|
1232
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1233
|
+
case 403:
|
|
1234
|
+
// no permissions
|
|
1235
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1236
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1237
|
+
ttlWarning: null
|
|
1238
|
+
});
|
|
1239
|
+
throw err;
|
|
1240
|
+
case 409:
|
|
1241
|
+
// already exists
|
|
1242
|
+
logger.info({ msg: 'Subscription already exists', app: appData.id, subscription: subscriptionName });
|
|
1243
|
+
break;
|
|
1244
|
+
default:
|
|
1245
|
+
throw err;
|
|
1246
|
+
}
|
|
1088
1247
|
}
|
|
1248
|
+
break;
|
|
1089
1249
|
}
|
|
1090
|
-
|
|
1250
|
+
default:
|
|
1251
|
+
throw err;
|
|
1091
1252
|
}
|
|
1092
|
-
default:
|
|
1093
|
-
throw err;
|
|
1094
1253
|
}
|
|
1095
|
-
}
|
|
1096
1254
|
|
|
1097
|
-
|
|
1255
|
+
// Step 4. Grant access to Gmail publisher
|
|
1098
1256
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1257
|
+
let existingPolicy;
|
|
1258
|
+
let getIamPolicyRes;
|
|
1259
|
+
try {
|
|
1260
|
+
// Check for an existing policy grant
|
|
1261
|
+
getIamPolicyRes = await client.request(accessToken, `${topicUrl}:getIamPolicy`, 'GET');
|
|
1262
|
+
existingPolicy = getIamPolicyRes?.bindings?.find(binding => binding?.role === role && binding?.members?.includes(member));
|
|
1263
|
+
/*
|
|
1105
1264
|
{
|
|
1106
1265
|
version: 1,
|
|
1107
1266
|
etag: 'BwYbtWmb5c0=',
|
|
@@ -1113,77 +1272,93 @@ class OAuth2AppsHandler {
|
|
|
1113
1272
|
]
|
|
1114
1273
|
}
|
|
1115
1274
|
*/
|
|
1116
|
-
} catch (err) {
|
|
1117
|
-
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1118
|
-
logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1119
|
-
throw err;
|
|
1120
|
-
}
|
|
1121
|
-
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1122
|
-
case 403:
|
|
1123
|
-
// no permissions
|
|
1124
|
-
this.setMeta(appData.id, {
|
|
1125
|
-
authFlag: {
|
|
1126
|
-
message: 'Service client does not have permission to view Pub/Sub topics. Grant the service user the "Pub/Sub Admin" role.'
|
|
1127
|
-
}
|
|
1128
|
-
});
|
|
1129
|
-
throw err;
|
|
1130
|
-
default:
|
|
1131
|
-
throw err;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
if (existingPolicy) {
|
|
1136
|
-
logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
|
|
1137
|
-
} else {
|
|
1138
|
-
logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
|
|
1139
|
-
const policyPayload = {
|
|
1140
|
-
policy: {
|
|
1141
|
-
bindings: [
|
|
1142
|
-
{
|
|
1143
|
-
members: [member],
|
|
1144
|
-
role
|
|
1145
|
-
}
|
|
1146
|
-
]
|
|
1147
|
-
}
|
|
1148
|
-
};
|
|
1149
|
-
try {
|
|
1150
|
-
await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
|
|
1151
|
-
results.iamPolicy = {
|
|
1152
|
-
members: [member],
|
|
1153
|
-
role
|
|
1154
|
-
};
|
|
1155
|
-
|
|
1156
|
-
await this.update(
|
|
1157
|
-
appData.id,
|
|
1158
|
-
{
|
|
1159
|
-
pubSubIamPolicy: results.iamPolicy
|
|
1160
|
-
},
|
|
1161
|
-
{ partial: true }
|
|
1162
|
-
);
|
|
1163
1275
|
} catch (err) {
|
|
1164
1276
|
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1165
|
-
logger.warn({ msg: 'Network error
|
|
1277
|
+
logger.warn({ msg: 'Network error checking Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1166
1278
|
throw err;
|
|
1167
1279
|
}
|
|
1168
1280
|
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1169
1281
|
case 403:
|
|
1170
1282
|
// no permissions
|
|
1171
|
-
this.
|
|
1172
|
-
authFlag: {
|
|
1173
|
-
|
|
1174
|
-
}
|
|
1283
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1284
|
+
authFlag: { message: PUBSUB_PERM_VIEW },
|
|
1285
|
+
ttlWarning: null
|
|
1175
1286
|
});
|
|
1176
1287
|
throw err;
|
|
1177
1288
|
default:
|
|
1178
1289
|
throw err;
|
|
1179
1290
|
}
|
|
1180
1291
|
}
|
|
1181
|
-
}
|
|
1182
1292
|
|
|
1183
|
-
|
|
1184
|
-
|
|
1293
|
+
if (existingPolicy) {
|
|
1294
|
+
logger.debug({ msg: 'Gmail publisher policy already exists', app: appData.id, topic: topicName });
|
|
1295
|
+
} else {
|
|
1296
|
+
logger.debug({ msg: 'Granting access to Gmail publisher', app: appData.id, topic: topicName });
|
|
1297
|
+
let existingBindings = (getIamPolicyRes && getIamPolicyRes.bindings) || [];
|
|
1298
|
+
const policyPayload = {
|
|
1299
|
+
policy: {
|
|
1300
|
+
bindings: existingBindings.concat([
|
|
1301
|
+
{
|
|
1302
|
+
members: [member],
|
|
1303
|
+
role
|
|
1304
|
+
}
|
|
1305
|
+
])
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
if (getIamPolicyRes && getIamPolicyRes.etag) {
|
|
1309
|
+
policyPayload.policy.etag = getIamPolicyRes.etag;
|
|
1310
|
+
}
|
|
1311
|
+
try {
|
|
1312
|
+
await client.request(accessToken, `${topicUrl}:setIamPolicy`, 'POST', policyPayload);
|
|
1313
|
+
results.iamPolicy = {
|
|
1314
|
+
members: [member],
|
|
1315
|
+
role
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
await this.update(
|
|
1319
|
+
appData.id,
|
|
1320
|
+
{
|
|
1321
|
+
pubSubIamPolicy: results.iamPolicy
|
|
1322
|
+
},
|
|
1323
|
+
{ partial: true }
|
|
1324
|
+
);
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
if (TRANSIENT_NETWORK_CODES.has(err.code)) {
|
|
1327
|
+
logger.warn({ msg: 'Network error setting Pub/Sub IAM policy', app: appData.id, code: err.code });
|
|
1328
|
+
throw err;
|
|
1329
|
+
}
|
|
1330
|
+
switch (err?.oauthRequest?.response?.error?.code) {
|
|
1331
|
+
case 403:
|
|
1332
|
+
// no permissions
|
|
1333
|
+
this.setMetaFireAndForget(appData.id, {
|
|
1334
|
+
authFlag: { message: PUBSUB_PERM_MANAGE },
|
|
1335
|
+
ttlWarning: null
|
|
1336
|
+
});
|
|
1337
|
+
throw err;
|
|
1338
|
+
default:
|
|
1339
|
+
throw err;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// clear auth flag if everything worked; set or clear TTL warning
|
|
1345
|
+
let metaUpdate = { authFlag: null };
|
|
1346
|
+
if (ttlPatchFailed) {
|
|
1347
|
+
metaUpdate.ttlWarning = {
|
|
1348
|
+
message: 'Failed to update expiration policy on subscription',
|
|
1349
|
+
description: [ttlPatchFailed.message, ttlPatchFailed.code].filter(Boolean).join('; ')
|
|
1350
|
+
};
|
|
1351
|
+
} else {
|
|
1352
|
+
metaUpdate.ttlWarning = null;
|
|
1353
|
+
}
|
|
1354
|
+
await this.setMeta(appData.id, metaUpdate);
|
|
1185
1355
|
|
|
1186
|
-
|
|
1356
|
+
return results;
|
|
1357
|
+
} finally {
|
|
1358
|
+
if (ensureLock?.success) {
|
|
1359
|
+
await lock.releaseLock(ensureLock);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1187
1362
|
}
|
|
1188
1363
|
|
|
1189
1364
|
async getClient(id, extraOpts) {
|
|
@@ -1202,7 +1377,7 @@ class OAuth2AppsHandler {
|
|
|
1202
1377
|
let redirectUrl = appData.redirectUrl;
|
|
1203
1378
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
|
|
1204
1379
|
|
|
1205
|
-
let googleProjectId = appData.
|
|
1380
|
+
let googleProjectId = appData.googleProjectId;
|
|
1206
1381
|
let workspaceAccounts = appData.googleWorkspaceAccounts;
|
|
1207
1382
|
|
|
1208
1383
|
if (!clientId || !clientSecret || !redirectUrl) {
|
|
@@ -1245,7 +1420,7 @@ class OAuth2AppsHandler {
|
|
|
1245
1420
|
|
|
1246
1421
|
let scopes = formatExtraScopes(appData.extraScopes, appData.baseScopes, GMAIL_SCOPES, appData.skipScopes);
|
|
1247
1422
|
|
|
1248
|
-
let googleProjectId = appData.
|
|
1423
|
+
let googleProjectId = appData.googleProjectId;
|
|
1249
1424
|
let workspaceAccounts = appData.googleWorkspaceAccounts;
|
|
1250
1425
|
|
|
1251
1426
|
if (!serviceClient || !serviceKey) {
|
|
@@ -1445,7 +1620,9 @@ class OAuth2AppsHandler {
|
|
|
1445
1620
|
});
|
|
1446
1621
|
throw err;
|
|
1447
1622
|
} finally {
|
|
1448
|
-
|
|
1623
|
+
if (renewLock?.success) {
|
|
1624
|
+
await lock.releaseLock(renewLock);
|
|
1625
|
+
}
|
|
1449
1626
|
}
|
|
1450
1627
|
}
|
|
1451
1628
|
}
|