diff --git a/src/Github/Router.ts b/src/Github/Router.ts index b0f4b529..5f92b24d 100644 --- a/src/Github/Router.ts +++ b/src/Github/Router.ts @@ -1,5 +1,6 @@ import { Router, Request, Response } from "express"; import { BridgeConfigGitHub } from "../Config/Config"; +import { ApiError, ErrCode } from "../provisioning/api"; import { UserTokenStore } from "../UserTokenStore"; import { generateGitHubOAuthUrl } from "./AdminCommands"; @@ -13,6 +14,9 @@ export class GitHubProvisionerRouter { } private onOAuth(req: Request, res: Response<{url: string}>) { + if (!this.config.oauth) { + throw new ApiError("GitHub is not configured to support OAuth", ErrCode.UnsupportedOperation); + } res.send({ url: generateGitHubOAuthUrl(this.config.oauth.client_id, this.config.oauth.redirect_uri, this.tokenStore.createStateForOAuth(req.query.userId)) }); diff --git a/src/Jira/Client.ts b/src/Jira/Client.ts index 16ca99ed..0ab31a78 100644 --- a/src/Jira/Client.ts +++ b/src/Jira/Client.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; +import axios, { Method } from 'axios'; import JiraApi, { SearchUserOptions } from 'jira-client'; import QuickLRU from "@alloc/quick-lru"; import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types'; @@ -10,23 +10,53 @@ const log = new LogWrapper("JiraClient"); const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100; const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000; +interface JiraProjectSearchResponse { + nextPage: string; + maxResults: number; + startAt: number; + isLast: boolean; + values: JiraProject[]; +} + export class HookshotJiraApi extends JiraApi { - constructor(private options: JiraApi.JiraApiOptions) { + constructor(private options: JiraApi.JiraApiOptions, private readonly res: JiraAPIAccessibleResource) { super(options); } + public get resource() { + return this.res; + } + + private async apiRequest(path: string, method?: Method, data?: undefined): Promise + private async apiRequest(path: string, method: Method, data?: R): Promise { + const url = `https://api.atlassian.com/${this.options.base}${path}`; + const res = await axios.request({ url, + method: method || "GET", + data, + headers: { + Authorization: `Bearer ${this.options.bearer}` + }, + responseType: 'json', + }); + return res.data; + } + async getProject(projectIdOrKey: string): Promise { return await super.getProject(projectIdOrKey) as JiraProject; } async getIssue(issueIdOrKey: string): Promise { - const res = await axios.get(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, { - headers: { - Authorization: `Bearer ${this.options.bearer}` - }, - responseType: 'json', - }); - return res.data; + return this.apiRequest(`/rest/api/3/issue/${issueIdOrKey}`); + } + + async * getAllProjects(status = "live"): AsyncIterable { + let response; + let startAt = 0; + do { + response = await this.apiRequest(`/rest/api/3/project/search?startAt=${startAt}&status=${status}`); + yield* response.values; + startAt += response.maxResults; + } while(!response.isLast) } async searchUsers(opts: SearchUserOptions): Promise { @@ -102,6 +132,14 @@ export class JiraClient { return this.getClientForResource(resource); } + async getClientForName(name: string) { + const resource = (await this.getAccessibleResources()).find((r) => r.name === name); + if (!resource) { + return null; + } + return this.getClientForResource(resource); + } + async getClientForResource(res: JiraAPIAccessibleResource) { // Check token age await this.checkTokenAge(); @@ -112,6 +150,6 @@ export class JiraClient { apiVersion: '3', strictSSL: true, bearer: this.bearer, - }); + }, res); } } \ No newline at end of file diff --git a/src/Jira/Router.ts b/src/Jira/Router.ts index 3aaae836..107701aa 100644 --- a/src/Jira/Router.ts +++ b/src/Jira/Router.ts @@ -1,19 +1,26 @@ import { BridgeConfigJira } from "../Config/Config"; import { generateJiraURL } from "./AdminCommands"; -import { JiraOAuthResult } from "./Types"; +import { JiraOAuthResult, JiraProject } from "./Types"; import { MessageQueue } from "../MessageQueue"; import { OAuthRequest } from "../WebhookTypes"; -import { Router, Request, Response } from "express"; +import { Router, Request, Response, NextFunction } from "express"; import { UserTokenStore } from "../UserTokenStore"; import axios from "axios"; import LogWrapper from "../LogWrapper"; +import { ApiError, ErrCode } from "../provisioning/api"; +import { HookshotJiraApi } from "./Client"; const log = new LogWrapper("JiraRouter"); +interface OAuthQuery { + state: string; + code: string; +} + export class JiraWebhooksRouter { constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { } - private async onOAuth(req: Request, res: Response) { + private async onOAuth(req: Request, res: Response) { if (typeof req.query.state !== "string") { return res.status(400).send({error: "Missing 'state' parameter"}); } @@ -64,12 +71,28 @@ export class JiraWebhooksRouter { } +interface JiraAccountStatus { + loggedIn: boolean; + instances?: { + name: string; + url: string; + id: string; + }[] +} +interface JiraProjectsListing { + name: string; + key: string; + url: string; +} + export class JiraProvisionerRouter { constructor(private readonly config: BridgeConfigJira, private readonly tokenStore: UserTokenStore) { } public getRouter() { const router = Router(); router.get("/oauth", this.onOAuth.bind(this)); + router.get("/account", this.onGetAccount.bind(this)); + router.get("/instances/:instanceName/projects", this.onGetInstanceProjects.bind(this)); return router; } @@ -78,4 +101,66 @@ export class JiraProvisionerRouter { url: generateJiraURL(this.config.oauth.client_id, this.config.oauth.redirect_uri, this.tokenStore.createStateForOAuth(req.query.userId)) }); } + + private async onGetAccount(req: Request, res: Response, next: NextFunction) { + const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId); + if (!jiraUser) { + return res.send({ + loggedIn: false, + }); + } + const instances = []; + try { + for (const resource of await jiraUser.getAccessibleResources()) { + instances.push({ + url: resource.url, + name: resource.name, + id: resource.id, + }); + } + } catch (ex) { + log.warn(`Failed to fetch accessible resources for ${req.query.userId}`, ex); + return next( new ApiError("Could not fetch accessible resources for JIRA user", ErrCode.Unknown)); + } + return res.send({ + loggedIn: true, + instances: instances + }) + } + + private async onGetInstanceProjects(req: Request<{instanceName: string}, undefined, undefined, {userId: string}>, res: Response, next: NextFunction) { + const jiraUser = await this.tokenStore.getJiraForUser(req.query.userId); + if (!jiraUser) { + // TODO: Better error? + return next( new ApiError("Not logged in", ErrCode.ForbiddenUser)); + } + + let resClient: HookshotJiraApi|null; + try { + resClient = await jiraUser.getClientForName(req.params.instanceName); + } catch (ex) { + log.warn(`Failed to fetch client for ${req.params.instanceName} for ${req.query.userId}`, ex); + return next( new ApiError("Could not fetch accessible resources for JIRA user", ErrCode.Unknown)); + } + if (!resClient) { + return next( new ApiError("Instance not known or not accessible to this user", ErrCode.ForbiddenUser)); + } + + const projects = []; + try { + for await (const project of resClient.getAllProjects()) { + projects.push({ + key: project.key, + name: project.name, + // Technically not the real URL, but good enough for hookshot! + url: `${resClient.resource.url}/projects/${project.key}`, + }); + } + } catch (ex) { + log.warn(`Failed to fetch accessible projects for ${req.params.instanceName} / ${req.query.userId}`, ex); + return next( new ApiError("Could not fetch accessible projects for JIRA user", ErrCode.Unknown)); + } + + return res.send(projects); + } } diff --git a/src/provisioning/api.ts b/src/provisioning/api.ts index a443954b..5883a6ec 100644 --- a/src/provisioning/api.ts +++ b/src/provisioning/api.ts @@ -47,6 +47,10 @@ export enum ErrCode { * The secret token provided to the API was invalid or not given. */ BadToken = "HS_BAD_TOKEN", + /** + * The requested feature is not enabled in the bridge. + */ + DisabledFeature = "HS_DISABLED_FEATURE", } const ErrCodeToStatusCode: Record = { @@ -58,6 +62,7 @@ const ErrCodeToStatusCode: Record = { HS_NOT_IN_ROOM: 403, HS_BAD_VALUE: 400, HS_BAD_TOKEN: 401, + HS_DISABLED_FEATURE: 500, } export class ApiError extends Error {