UBERF-7836: Fix github integeration (#6313)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-08-11 18:09:36 +07:00 committed by GitHub
parent db6cb9fde7
commit e4ed3bd8af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 160 additions and 40 deletions

1
.vscode/launch.json vendored
View File

@ -244,6 +244,7 @@
"MINIO_ENDPOINT": "localhost", "MINIO_ENDPOINT": "localhost",
"MINIO_ACCESS_KEY": "minioadmin", "MINIO_ACCESS_KEY": "minioadmin",
"MINIO_SECRET_KEY": "minioadmin", "MINIO_SECRET_KEY": "minioadmin",
"PLATFORM_OPERATION_LOGGING": "true",
"FRONT_URL": "http://localhost:8080", "FRONT_URL": "http://localhost:8080",
"PORT": "3500" "PORT": "3500"
}, },

View File

@ -65,7 +65,7 @@ import {
registerStringLoaders registerStringLoaders
} from '@hcengineering/server-pipeline' } from '@hcengineering/server-pipeline'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import { decodeToken, generateToken } from '@hcengineering/server-token' import { decodeToken as decodeTokenRaw, generateToken, type Token } from '@hcengineering/server-token'
import toolPlugin, { import toolPlugin, {
connect, connect,
initializeWorkspace, initializeWorkspace,
@ -398,7 +398,7 @@ export async function getAccountInfoByToken (
let email: string = '' let email: string = ''
let workspace: WorkspaceId let workspace: WorkspaceId
try { try {
;({ email, workspace } = decodeToken(token)) ;({ email, workspace } = decodeToken(ctx, token))
} catch (err: any) { } catch (err: any) {
Analytics.handleError(err) Analytics.handleError(err)
ctx.error('Invalid token', { token }) ctx.error('Invalid token', { token })
@ -579,6 +579,21 @@ function getExtra (info: Account | AccountInfo | null, rec?: Record<string, any>
export const guestAccountEmail = '#guest@hc.engineering' export const guestAccountEmail = '#guest@hc.engineering'
function decodeToken (ctx: MeasureContext, token: string): Token {
// eslint-disable-next-line no-useless-catch
try {
return decodeTokenRaw(token)
} catch (err: any) {
try {
// Ok we have error, but we need to log a proper message
ctx.warn('failed to verify token', { ...decodeTokenRaw(token, false) })
} catch (err2: any) {
// Ignore
}
throw err
}
}
/** /**
* @public * @public
*/ */
@ -592,7 +607,7 @@ export async function selectWorkspace (
kind: 'external' | 'internal', kind: 'external' | 'internal',
allowAdmin: boolean = true allowAdmin: boolean = true
): Promise<WorkspaceLoginInfo> { ): Promise<WorkspaceLoginInfo> {
const decodedToken = decodeToken(token) const decodedToken = decodeToken(ctx, token)
const email = cleanEmail(decodedToken.email) const email = cleanEmail(decodedToken.email)
const endpointKind = kind === 'external' ? EndpointKind.External : EndpointKind.Internal const endpointKind = kind === 'external' ? EndpointKind.External : EndpointKind.Internal
@ -776,7 +791,7 @@ export async function confirm (
branding: Branding | null, branding: Branding | null,
token: string token: string
): Promise<LoginInfo> { ): Promise<LoginInfo> {
const decode = decodeToken(token) const decode = decodeToken(ctx, token)
const _email = decode.extra?.confirm const _email = decode.extra?.confirm
if (_email === undefined) { if (_email === undefined) {
ctx.error('confirm email invalid', { token: decode }) ctx.error('confirm email invalid', { token: decode })
@ -1010,7 +1025,7 @@ export async function listWorkspaces (
branding: Branding | null, branding: Branding | null,
token: string token: string
): Promise<WorkspaceInfo[]> { ): Promise<WorkspaceInfo[]> {
decodeToken(token) // Just verify token is valid decodeToken(ctx, token) // Just verify token is valid
return (await db.collection<Workspace>(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray()) return (await db.collection<Workspace>(WORKSPACE_COLLECTION).find(withProductId(productId, {})).toArray())
.map((it) => ({ ...it, productId })) .map((it) => ({ ...it, productId }))
.filter((it) => it.disabled !== true) .filter((it) => it.disabled !== true)
@ -1447,7 +1462,7 @@ export const createUserWorkspace =
token: string, token: string,
workspaceName: string workspaceName: string
): Promise<LoginInfo> => { ): Promise<LoginInfo> => {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
ctx.info('Creating workspace', { workspaceName, email }) ctx.info('Creating workspace', { workspaceName, email })
@ -1567,7 +1582,7 @@ export async function getInviteLink (
role?: AccountRole, role?: AccountRole,
personId?: Ref<Person> personId?: Ref<Person>
): Promise<ObjectId> { ): Promise<ObjectId> {
const { workspace, email } = decodeToken(token) const { workspace, email } = decodeToken(ctx, token)
const wsPromise = await getWorkspaceById(db, productId, workspace.name) const wsPromise = await getWorkspaceById(db, productId, workspace.name)
if (wsPromise === null) { if (wsPromise === null) {
ctx.error('workspace not found', { workspace, email }) ctx.error('workspace not found', { workspace, email })
@ -1620,7 +1635,7 @@ export async function getUserWorkspaces (
branding: Branding | null, branding: Branding | null,
token: string token: string
): Promise<ClientWorkspaceInfo[]> { ): Promise<ClientWorkspaceInfo[]> {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
const account = await getAccount(db, email) const account = await getAccount(db, email)
if (account === null) { if (account === null) {
ctx.error('account not found', { email }) ctx.error('account not found', { email })
@ -1648,7 +1663,7 @@ export async function getWorkspaceInfo (
token: string, token: string,
_updateLastVisit: boolean = false _updateLastVisit: boolean = false
): Promise<ClientWorkspaceInfo> { ): Promise<ClientWorkspaceInfo> {
const { email, workspace, extra } = decodeToken(token) const { email, workspace, extra } = decodeToken(ctx, token)
const guest = extra?.guest === 'true' const guest = extra?.guest === 'true'
let account: Pick<Account, 'admin' | 'workspaces'> | Account | null = null let account: Pick<Account, 'admin' | 'workspaces'> | Account | null = null
const query: Filter<Workspace> = { const query: Filter<Workspace> = {
@ -1772,7 +1787,7 @@ export async function createMissingEmployee (
branding: Branding | null, branding: Branding | null,
token: string token: string
): Promise<void> { ): Promise<void> {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
const wsInfo = await getWorkspaceInfo(ctx, db, productId, branding, token) const wsInfo = await getWorkspaceInfo(ctx, db, productId, branding, token)
const account = await getAccount(db, email) const account = await getAccount(db, email)
@ -2020,7 +2035,7 @@ export async function changePassword (
oldPassword: string, oldPassword: string,
password: string password: string
): Promise<void> { ): Promise<void> {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
const account = await getAccountInfo(ctx, db, productId, branding, email, oldPassword) const account = await getAccountInfo(ctx, db, productId, branding, email, oldPassword)
const salt = randomBytes(32) const salt = randomBytes(32)
@ -2121,7 +2136,7 @@ export async function restorePassword (
token: string, token: string,
password: string password: string
): Promise<LoginInfo> { ): Promise<LoginInfo> {
const decode = decodeToken(token) const decode = decodeToken(ctx, token)
const email = decode.extra?.restore const email = decode.extra?.restore
if (email === undefined) { if (email === undefined) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email })) throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: email }))
@ -2180,7 +2195,7 @@ export async function checkJoin (
token: string, token: string,
inviteId: ObjectId inviteId: ObjectId
): Promise<WorkspaceLoginInfo> { ): Promise<WorkspaceLoginInfo> {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
const invite = await getInvite(db, inviteId) const invite = await getInvite(db, inviteId)
const workspace = await checkInvite(ctx, invite, email) const workspace = await checkInvite(ctx, invite, email)
const ws = await getWorkspaceById(db, productId, workspace.name) const ws = await getWorkspaceById(db, productId, workspace.name)
@ -2283,7 +2298,7 @@ export async function leaveWorkspace (
token: string, token: string,
email: string email: string
): Promise<void> { ): Promise<void> {
const tokenData = decodeToken(token) const tokenData = decodeToken(ctx, token)
const currentAccount = await getAccount(db, tokenData.email) const currentAccount = await getAccount(db, tokenData.email)
if (currentAccount === null) { if (currentAccount === null) {
@ -2324,7 +2339,7 @@ export async function sendInvite (
personId?: Ref<Person>, personId?: Ref<Person>,
role?: AccountRole role?: AccountRole
): Promise<void> { ): Promise<void> {
const tokenData = decodeToken(token) const tokenData = decodeToken(ctx, token)
const currentAccount = await getAccount(db, tokenData.email) const currentAccount = await getAccount(db, tokenData.email)
if (currentAccount === null) { if (currentAccount === null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email })) throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: tokenData.email }))
@ -2449,6 +2464,13 @@ function wrap (
err instanceof PlatformError err instanceof PlatformError
? err.status ? err.status
: new Status(Severity.ERROR, platform.status.InternalServerError, {}) : new Status(Severity.ERROR, platform.status.InternalServerError, {})
if (((err.message as string) ?? '') === 'Signature verification failed') {
// Let's send un authorized
return {
error: new Status(Severity.ERROR, platform.status.Unauthorized, {})
}
}
if (status.code === platform.status.InternalServerError) { if (status.code === platform.status.InternalServerError) {
Analytics.handleError(err) Analytics.handleError(err)
ctx.error('error', { status, err }) ctx.error('error', { status, err })
@ -2617,7 +2639,7 @@ export async function changeUsername (
first: string, first: string,
last: string last: string
): Promise<void> { ): Promise<void> {
const { email } = decodeToken(token) const { email } = decodeToken(ctx, token)
const account = await getAccount(db, email) const account = await getAccount(db, email)
if (account == null) { if (account == null) {

View File

@ -26,8 +26,8 @@ export function generateToken (email: string, workspace: WorkspaceId, extra?: Re
/** /**
* @public * @public
*/ */
export function decodeToken (token: string): Token { export function decodeToken (token: string, verify: boolean = true): Token {
const value = decode(token, getSecret(), false) const value = decode(token, getSecret(), !verify)
const { email, workspace, productId, ...extra } = value const { email, workspace, productId, ...extra } = value
return { email, workspace: getWorkspaceId(workspace, productId), extra } return { email, workspace: getWorkspaceId(workspace, productId), extra }
} }

View File

@ -316,7 +316,9 @@ class TSessionManager implements SessionManager {
) { ) {
ctx.warn('model version mismatch', { ctx.warn('model version mismatch', {
version: this.modelVersion, version: this.modelVersion,
workspaceVersion: versionToString(workspaceInfo.version) workspaceVersion: versionToString(workspaceInfo.version),
workspace: workspaceInfo.workspaceId,
workspaceUrl: workspaceInfo.workspaceUrl
}) })
// Version mismatch, return upgrading. // Version mismatch, return upgrading.
return { upgrade: true, upgradeInfo: workspaceInfo.upgrade } return { upgrade: true, upgradeInfo: workspaceInfo.upgrade }

View File

@ -5,6 +5,7 @@ WORKDIR /usr/src/app
RUN npm install --ignore-scripts=false --verbose bufferutil utf-8-validate @mongodb-js/zstd snappy --unsafe-perm RUN npm install --ignore-scripts=false --verbose bufferutil utf-8-validate @mongodb-js/zstd snappy --unsafe-perm
COPY bundle/bundle.js ./ COPY bundle/bundle.js ./
COPY bundle/bundle.js.map ./
RUN apt-get update RUN apt-get update
RUN apt-get install libjemalloc2 RUN apt-get install libjemalloc2
@ -13,4 +14,4 @@ ENV LD_PRELOAD=libjemalloc.so.2
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
EXPOSE 3078 EXPOSE 3078
CMD [ "node", "--inspect", "--async-stack-traces", "bundle.js" ] CMD [ "node", "--inspect", "--async-stack-traces", "--enable-source-maps", "bundle.js" ]

View File

@ -15,8 +15,8 @@ import core, {
Ref, Ref,
TxOperations TxOperations
} from '@hcengineering/core' } from '@hcengineering/core'
import github, { GithubAuthentication, makeQuery } from '@hcengineering/github' import github, { GithubAuthentication, makeQuery, type GithubIntegration } from '@hcengineering/github'
import { MongoClientReference, getMongoClient } from '@hcengineering/mongo' import { getMongoClient, MongoClientReference } from '@hcengineering/mongo'
import { setMetadata } from '@hcengineering/platform' import { setMetadata } from '@hcengineering/platform'
import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage'
import serverToken, { generateToken } from '@hcengineering/server-token' import serverToken, { generateToken } from '@hcengineering/server-token'
@ -199,9 +199,39 @@ export class PlatformWorker {
installationId: number, installationId: number,
accountId: Ref<Account> accountId: Ref<Account>
): Promise<void> { ): Promise<void> {
if (this.integrations.find((it) => it.installationId === installationId) != null) { const oldInstallation = this.integrations.find((it) => it.installationId === installationId)
if (oldInstallation != null) {
ctx.info('update integration', { workspace, installationId, accountId })
// What to do with installation in different workspace? // What to do with installation in different workspace?
// Let's remove it and sync to new one. // Let's remove it and sync to new one.
if (oldInstallation.workspace !== workspace) {
//
const oldWorkspace = oldInstallation.workspace
await this.integrationCollection.updateOne(
{ installationId: oldInstallation.installationId },
{ $set: { workspace } }
)
oldInstallation.workspace = workspace
const oldWorker = this.clients.get(oldWorkspace) as GithubWorker
if (oldWorker !== undefined) {
await this.removeInstallationFromWorkspace(oldWorker.client, installationId)
await oldWorker.reloadRepositories(installationId)
} else {
let client: Client | undefined
try {
client = await createPlatformClient(oldWorkspace, config.ProductID, 30000)
await this.removeInstallationFromWorkspace(oldWorker, installationId)
await client.close()
} catch (err: any) {
ctx.error('failed to remove old installation from workspace', { workspace: oldWorkspace, installationId })
}
}
}
await this.updateInstallation(installationId)
const worker = this.clients.get(workspace) as GithubWorker const worker = this.clients.get(workspace) as GithubWorker
await worker?.reloadRepositories(installationId) await worker?.reloadRepositories(installationId)
worker?.triggerUpdate() worker?.triggerUpdate()
@ -214,7 +244,9 @@ export class PlatformWorker {
installationId, installationId,
accountId accountId
} }
await ctx.withLog('add integration', { workspace, installationId, accountId }, async (ctx) => { ctx.info('add integration', { workspace, installationId, accountId })
await ctx.with('add integration', { workspace, installationId, accountId }, async (ctx) => {
await this.integrationCollection.insertOne(record) await this.integrationCollection.insertOne(record)
this.integrations.push(record) this.integrations.push(record)
}) })
@ -228,12 +260,43 @@ export class PlatformWorker {
this.triggerCheckWorkspaces() this.triggerCheckWorkspaces()
} }
private async removeInstallationFromWorkspace (client: Client, installationId: number): Promise<void> {
const wsIntegerations = await client.findAll(github.class.GithubIntegration, { installationId })
for (const intValue of wsIntegerations) {
const ops = new TxOperations(client, core.account.System)
await ops.remove<GithubIntegration>(intValue)
}
}
async removeInstallation (ctx: MeasureContext, workspace: string, installationId: number): Promise<void> { async removeInstallation (ctx: MeasureContext, workspace: string, installationId: number): Promise<void> {
const installation = this.installations.get(installationId) const installation = this.installations.get(installationId)
if (installation !== undefined) { if (installation !== undefined) {
await installation.octokit.rest.apps.deleteInstallation({ try {
installation_id: installationId await installation.octokit.rest.apps.deleteInstallation({
}) installation_id: installationId
})
} catch (err: any) {
if (err.status !== 404) {
// Already deleted.
ctx.error('error from github api', { error: err })
}
await this.handleInstallationEventDelete(installationId)
}
// Let's check if workspace somehow still have installation and remove it
const worker = this.clients.get(workspace) as GithubWorker
if (worker !== undefined) {
await GithubWorker.checkIntegrations(worker.client, this.installations)
} else {
let client: Client | undefined
try {
client = await createPlatformClient(workspace, config.ProductID, 30000)
await GithubWorker.checkIntegrations(client, this.installations)
await client.close()
} catch (err: any) {
ctx.error('failed to clean installation from workspace', { workspace, installationId })
}
}
} }
this.triggerCheckWorkspaces() this.triggerCheckWorkspaces()
} }
@ -578,6 +641,12 @@ export class PlatformWorker {
const rateLimiter = new RateLimiter(5) const rateLimiter = new RateLimiter(5)
let errors = 0 let errors = 0
let idx = 0 let idx = 0
const connecting = new Map<string, number>()
const connectingInfo = setInterval(() => {
for (const [c, d] of connecting.entries()) {
this.ctx.info('connecting to workspace', { workspace: c, time: Date.now() - d })
}
}, 5000)
for (const workspace of workspaces) { for (const workspace of workspaces) {
const widx = ++idx const widx = ++idx
if (this.clients.has(workspace)) { if (this.clients.has(workspace)) {
@ -607,8 +676,14 @@ export class PlatformWorker {
errors++ errors++
return return
} }
if (workspaceInfo?.disabled === true) {
this.ctx.error('Workspace is disabled workspaceId', { workspace })
return
}
const branding = Object.values(this.brandingMap).find((b) => b.key === workspaceInfo?.branding) ?? null const branding = Object.values(this.brandingMap).find((b) => b.key === workspaceInfo?.branding) ?? null
const workerCtx = this.ctx.newChild('worker', { workspace: workspaceInfo.workspace }, {}) const workerCtx = this.ctx.newChild('worker', { workspace: workspaceInfo.workspace }, {})
connecting.set(workspaceInfo.workspace, Date.now())
workerCtx.info('************************* Register worker ************************* ', { workerCtx.info('************************* Register worker ************************* ', {
workspaceId: workspaceInfo.workspaceId, workspaceId: workspaceInfo.workspaceId,
workspace: workspaceInfo.workspace, workspace: workspaceInfo.workspace,
@ -658,6 +733,8 @@ export class PlatformWorker {
this.ctx.info("Couldn't create WS worker", { workspace, error: e }) this.ctx.info("Couldn't create WS worker", { workspace, error: e })
console.error(e) console.error(e)
errors++ errors++
} finally {
connecting.delete(workspaceInfo.workspace)
} }
}) })
} }
@ -667,6 +744,7 @@ export class PlatformWorker {
Analytics.handleError(e) Analytics.handleError(e)
errors++ errors++
} }
clearInterval(connectingInfo)
// Close deleted workspaces // Close deleted workspaces
for (const deleted of Array.from(toDelete.keys())) { for (const deleted of Array.from(toDelete.keys())) {
const ws = this.clients.get(deleted) const ws = this.clients.get(deleted)

View File

@ -73,13 +73,12 @@ export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Pro
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
app.post('/api/v1/installation', async (req, res) => { app.post('/api/v1/installation', async (req, res) => {
const payloadData: {
installationId: number
accountId: Ref<Account>
token: string
} = req.body
try { try {
const payloadData: {
installationId: number
accountId: Ref<Account>
token: string
} = req.body
const decodedToken = decodeToken(payloadData.token) const decodedToken = decodeToken(payloadData.token)
ctx.info('/api/v1/installation', { ctx.info('/api/v1/installation', {
email: decodedToken.email, email: decodedToken.email,
@ -87,6 +86,10 @@ export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Pro
body: req.body body: req.body
}) })
ctx.info('map-installation', {
workspace: decodedToken.workspace.name,
installationid: payloadData.installationId
})
await ctx.withLog('map-installation', {}, async (ctx) => { await ctx.withLog('map-installation', {}, async (ctx) => {
await worker.mapInstallation( await worker.mapInstallation(
ctx, ctx,
@ -99,6 +102,12 @@ export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Pro
res.json({}) res.json({})
} catch (err: any) { } catch (err: any) {
Analytics.handleError(err) Analytics.handleError(err)
const tok = decodeToken(payloadData.token, false)
ctx.error('failed to map-installation', {
workspace: tok.workspace?.name,
installationid: payloadData.installationId,
email: tok?.email
})
res.status(401) res.status(401)
res.json({ error: err.message }) res.json({ error: err.message })
} }
@ -121,7 +130,12 @@ export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Pro
} = JSON.parse(atob(payloadData.state)) } = JSON.parse(atob(payloadData.state))
const decodedToken = decodeToken(decodedData.token) const decodedToken = decodeToken(decodedData.token)
ctx.info('request github access-token', {
workspace: decodedToken.workspace.name,
accountId: payloadData.accountId,
code: payloadData.code,
state: payloadData.state
})
await ctx.withLog('request-github-access-token', {}, async (ctx) => { await ctx.withLog('request-github-access-token', {}, async (ctx) => {
await worker.requestGithubAccessToken({ await worker.requestGithubAccessToken({
workspace: decodedToken.workspace.name, workspace: decodedToken.workspace.name,
@ -154,7 +168,11 @@ export async function start (ctx: MeasureContext, brandingMap: BrandingMap): Pro
body: req.body body: req.body
}) })
await ctx.withLog('map-installation', {}, async (ctx) => { ctx.info('remove-installation', {
workspace: decodedToken.workspace.name,
installationId: payloadData.installationId
})
await ctx.withLog('remove-installation', {}, async (ctx) => {
await worker.removeInstallation(ctx, decodedToken.workspace.name, payloadData.installationId) await worker.removeInstallation(ctx, decodedToken.workspace.name, payloadData.installationId)
}) })
res.status(200) res.status(200)

View File

@ -624,7 +624,7 @@ export class GithubWorker implements IntegrationManager {
return statuses.filter((it) => allowedTypes.has(it._id)) return statuses.filter((it) => allowedTypes.has(it._id))
} }
async init (): Promise<boolean> { async init (): Promise<void> {
this.registerNotifyHandler() this.registerNotifyHandler()
await this.queryAccounts() await this.queryAccounts()
@ -692,7 +692,6 @@ export class GithubWorker implements IntegrationManager {
this.triggerRequests = 1 this.triggerRequests = 1
this.updateRequests = 1 this.updateRequests = 1
this.syncPromise = this.syncAndWait() this.syncPromise = this.syncAndWait()
return true
} }
projects: GithubProject[] = [] projects: GithubProject[] = []
@ -1504,9 +1503,8 @@ export class GithubWorker implements IntegrationManager {
branding branding
) )
ctx.info('Init worker', { workspace: workspace.workspaceUrl, workspaceId: workspace.workspaceName }) ctx.info('Init worker', { workspace: workspace.workspaceUrl, workspaceId: workspace.workspaceName })
if (await worker.init()) { void worker.init()
return worker return worker
}
} catch (err: any) { } catch (err: any) {
ctx.error('timeout during to connect', { workspace, error: err }) ctx.error('timeout during to connect', { workspace, error: err })
await client?.close() await client?.close()