diff --git a/Cargo.lock b/Cargo.lock index db477075..002de499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,7 +147,7 @@ checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" [[package]] name = "matrix-hookshot" -version = "1.7.2" +version = "1.7.3" dependencies = [ "contrast", "hex", diff --git a/changelog.d/388.bugfix b/changelog.d/388.bugfix new file mode 100644 index 00000000..32041dbe --- /dev/null +++ b/changelog.d/388.bugfix @@ -0,0 +1 @@ +GitHub admin room notifications will now continue to work if you reauthenticate with GitHub. \ No newline at end of file diff --git a/package.json b/package.json index dbaa0267..e677fefe 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "rss-parser": "^3.12.0", "source-map-support": "^0.5.21", "string-argv": "^0.3.1", + "tiny-typed-emitter": "^2.1.0", "uuid": "^8.3.2", "vm2": "^3.9.6", "winston": "^3.3.3", diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index 44e400c6..9a3f3ed0 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -60,7 +60,7 @@ export class AdminRoom extends AdminRoomCommandHandler { return this.widgetAccessToken === token; } - public notificationsEnabled(type: "github"|"gitlab", instanceName?: string) { + public notificationsEnabled(type: string, instanceName?: string) { if (type === "github") { return this.data.github?.notifications?.enabled; } diff --git a/src/Bridge.ts b/src/Bridge.ts index 1e8356df..ad650083 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -76,6 +76,7 @@ export class Bridge { this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); + this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); } @@ -1188,4 +1189,34 @@ export class Bridge { log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`); return adminRoom; } + + private onTokenUpdated(type: string, userId: string, token: string, instanceUrl?: string) { + let instanceName: string|undefined; + if (type === "gitlab") { + // TODO: Refactor our API to depend on either instanceUrl or instanceName. + instanceName = Object.entries(this.config.gitlab?.instances || {}).find(i => i[1].url === instanceUrl)?.[0]; + } else if (type === "github") { + // GitHub tokens are special + token = UserTokenStore.parseGitHubToken(token).access_token; + } else { + return; + } + for (const adminRoom of [...this.adminRooms.values()].filter(r => r.userId === userId)) { + if (adminRoom?.notificationsEnabled(type, instanceName)) { + log.debug(`Token was updated for ${userId} (${type}), notifying notification watcher`); + this.queue.push({ + eventName: "notifications.user.enable", + sender: "Bridge", + data: { + userId: adminRoom.userId, + roomId: adminRoom.roomId, + token, + filterParticipating: adminRoom.notificationsParticipating("github"), + type, + instanceUrl, + }, + }).catch(ex => log.error(`Failed to push notifications.user.enable:`, ex)); + } + } + } } diff --git a/src/Notifications/GitHubWatcher.ts b/src/Notifications/GitHubWatcher.ts index d5167bc3..40f700a6 100644 --- a/src/Notifications/GitHubWatcher.ts +++ b/src/Notifications/GitHubWatcher.ts @@ -34,14 +34,16 @@ export class GitHubWatcher extends EventEmitter implements NotificationWatcherTa private octoKit: Octokit; public failureCount = 0; private interval?: NodeJS.Timeout; - private lastReadTs = 0; public readonly type = "github"; public readonly instanceUrl = undefined; - constructor(token: string, baseUrl: URL, public userId: string, public roomId: string, since: number, private participating = false) { + constructor(token: string, baseUrl: URL, public userId: string, public roomId: string, private lastReadTs: number, private participating = false) { super(); this.octoKit = GithubInstance.createUserOctokit(token, baseUrl); - this.lastReadTs = since; + } + + public get since() { + return this.lastReadTs; } public start(intervalMs: number) { diff --git a/src/Notifications/NotificationWatcherTask.ts b/src/Notifications/NotificationWatcherTask.ts index 942d0490..b31375d9 100644 --- a/src/Notifications/NotificationWatcherTask.ts +++ b/src/Notifications/NotificationWatcherTask.ts @@ -8,6 +8,7 @@ export interface NotificationWatcherTask extends EventEmitter { instanceUrl?: string; roomId: string; failureCount: number; + since: number; start(intervalMs: number): void; stop(): void; } \ No newline at end of file diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index 3d876ed8..661d25bb 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -79,13 +79,18 @@ Check your token is still valid, and then turn notifications back on.`, "m.notic } let task: NotificationWatcherTask; const key = UserNotificationWatcher.constructMapKey(data.userId, data.type, data.instanceUrl); + const existing = this.userIntervals.get(key); + const since = data.since || existing?.since; + if (since === undefined) { + throw Error('`since` value missing from data payload, and no previous since value exists'); + } if (data.type === "github") { if (!this.config.github) { throw Error('GitHub is not configured'); } - task = new GitHubWatcher(data.token, this.config.github.baseUrl, data.userId, data.roomId, data.since, data.filterParticipating); + task = new GitHubWatcher(data.token, this.config.github.baseUrl, data.userId, data.roomId, since, data.filterParticipating); } else if (data.type === "gitlab" && data.instanceUrl) { - task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, data.since); + task = new GitLabWatcher(data.token, data.instanceUrl, data.userId, data.roomId, since); } else { throw Error('Notification type not known'); } diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts index 5d6a4b4f..f1e28131 100644 --- a/src/UserTokenStore.ts +++ b/src/UserTokenStore.ts @@ -16,6 +16,7 @@ import { JiraOnPremOAuth } from "./Jira/oauth/OnPremOAuth"; import { JiraOnPremClient } from "./Jira/client/OnPremClient"; import { JiraCloudClient } from "./Jira/client/CloudClient"; import { TokenError, TokenErrorCode } from "./errors"; +import { TypedEmitter } from "tiny-typed-emitter"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; @@ -52,12 +53,17 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?: const MAX_TOKEN_PART_SIZE = 128; const OAUTH_TIMEOUT_MS = 1000 * 60 * 30; -export class UserTokenStore { + +interface Emitter { + onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void, +} +export class UserTokenStore extends TypedEmitter { private key!: Buffer; private oauthSessionStore: Map = new Map(); private userTokens: Map; public readonly jiraOAuth?: JiraOAuth; constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) { + super(); this.userTokens = new Map(); if (config.jira?.oauth) { if ("client_id" in config.jira.oauth) { @@ -81,9 +87,10 @@ export class UserTokenStore { } const key = tokenKey(type, userId, false, instanceUrl); const tokenParts: string[] = []; - while (token && token.length > 0) { - const part = token.slice(0, MAX_TOKEN_PART_SIZE); - token = token.substring(MAX_TOKEN_PART_SIZE); + let tokenSource = token; + while (tokenSource && tokenSource.length > 0) { + const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); + tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64")); } const data: StoredTokenData = { @@ -94,6 +101,7 @@ export class UserTokenStore { this.userTokens.set(key, token); log.info(`Stored new ${type} token for ${userId}`); log.debug(`Stored`, data); + this.emit("onNewToken", type, userId, token, instanceUrl); } public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise { @@ -139,19 +147,22 @@ export class UserTokenStore { return null; } + public static parseGitHubToken(token: string): GitHubOAuthToken { + if (!token.startsWith('{')) { + // Old style token + return { access_token: token, token_type: 'pat' }; + } else { + return JSON.parse(token); + } + } + public async getGitHubToken(userId: string) { const storeTokenResponse = await this.getUserToken("github", userId); if (!storeTokenResponse) { return null; } - let senderToken: GitHubOAuthToken; - if (!storeTokenResponse.startsWith('{')) { - // Old style token - senderToken = { access_token: storeTokenResponse, token_type: 'pat' }; - } else { - senderToken = JSON.parse(storeTokenResponse); - } + let senderToken = UserTokenStore.parseGitHubToken(storeTokenResponse); const date = Date.now(); if (senderToken.expires_in && senderToken.expires_in < date) { log.info(`GitHub access token for ${userId} has expired ${senderToken.expires_in} < ${date}, attempting refresh`); diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 09150b7c..5afb198f 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -21,7 +21,7 @@ const log = new LogWrapper("Webhooks"); export interface NotificationsEnableEvent { userId: string; roomId: string; - since: number; + since?: number; token: string; filterParticipating: boolean; type: "github"|"gitlab"; diff --git a/yarn.lock b/yarn.lock index 9b9d506a..eb369ae4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4816,6 +4816,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@^7.5.2: + version "7.5.5" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.5.tgz#2ebad89af0f560f460ad5cc4213219e1f7dd4e9f" + integrity sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -5206,6 +5213,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= +tiny-typed-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" + integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -5264,6 +5276,11 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.1.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -5308,6 +5325,13 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" +typed-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb" + integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA== + optionalDependencies: + rxjs "^7.5.2" + typescript@^4.5.2: version "4.5.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8"