From 94db3e0de7cc9efab6669f66197ca3f713c5ffa1 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Mon, 4 Mar 2024 17:30:05 +0800 Subject: [PATCH] Add authorization with notion --- .../api/src/routers/integration_router.ts | 32 +++-------- .../api/src/services/integrations/index.ts | 6 ++- .../src/services/integrations/integration.ts | 2 + .../api/src/services/integrations/notion.ts | 54 +++++++++++++++++++ .../api/src/services/integrations/pocket.ts | 23 ++++++++ .../api/src/services/integrations/readwise.ts | 14 +++-- packages/api/src/util.ts | 14 +++++ 7 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 packages/api/src/services/integrations/notion.ts diff --git a/packages/api/src/routers/integration_router.ts b/packages/api/src/routers/integration_router.ts index cc6a520a7..d9a89f43a 100644 --- a/packages/api/src/routers/integration_router.ts +++ b/packages/api/src/routers/integration_router.ts @@ -2,6 +2,7 @@ import axios from 'axios' import cors from 'cors' import express from 'express' import { env } from '../env' +import { getIntegrationClient } from '../services/integrations' import { getClaimsByToken } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { logger } from '../utils/logger' @@ -10,10 +11,9 @@ export function integrationRouter() { const router = express.Router() // request token from pocket router.post( - '/pocket/auth', + '/:name/auth', cors(corsConfig), async (req: express.Request, res: express.Response) => { - logger.info('pocket/request-token') // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const token = (req.cookies.auth as string) || req.headers.authorization const claims = await getClaimsByToken(token) @@ -21,37 +21,19 @@ export function integrationRouter() { return res.status(401).send('UNAUTHORIZED') } - const consumerKey = env.pocket.consumerKey - const redirectUri = `${env.client.url}/settings/integrations` + const integrationClient = getIntegrationClient(req.params.name) + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const state = req.body.state as string try { - // make a POST request to Pocket to get a request token - const response = await axios.post<{ code: string }>( - 'https://getpocket.com/v3/oauth/request', - { - consumer_key: consumerKey, - redirect_uri: redirectUri, - }, - { - headers: { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', - }, - } - ) - const { code } = response.data + const redirectUri = await integrationClient.auth(state) // redirect the user to Pocket to authorize the request token - res.redirect( - `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( - `?pocketToken=${code}&state=${state}` - )}` - ) + res.redirect(redirectUri) } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) } else { - logger.error('pocket/request-token exception:', error) + logger.error(error) } res.redirect( diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 286ac59e7..435ca98e2 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -2,16 +2,20 @@ import { DeepPartial, FindOptionsWhere } from 'typeorm' import { Integration } from '../../entity/integration' import { authTrx } from '../../repository' import { IntegrationClient } from './integration' +import { NotionClient } from './notion' import { PocketClient } from './pocket' import { ReadwiseClient } from './readwise' const integrations: IntegrationClient[] = [ new ReadwiseClient(), new PocketClient(), + new NotionClient(), ] export const getIntegrationClient = (name: string): IntegrationClient => { - const service = integrations.find((s) => s.name === name) + const service = integrations.find( + (s) => s.name.toLowerCase() === name.toLowerCase() + ) if (!service) { throw new Error(`Integration client not found: ${name}`) } diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index e3f1edbc8..f5183f097 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -24,5 +24,7 @@ export interface IntegrationClient { accessToken(token: string): Promise + auth(state: string): Promise + export(token: string, items: LibraryItem[]): Promise } diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts new file mode 100644 index 000000000..8e8cedcad --- /dev/null +++ b/packages/api/src/services/integrations/notion.ts @@ -0,0 +1,54 @@ +import axios from 'axios' +import { env } from '../../env' +import { logger } from '../../utils/logger' +import { IntegrationClient } from './integration' + +export class NotionClient implements IntegrationClient { + name = 'notion' + apiUrl = 'https://api.notion.com/v1' + headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + } + + accessToken = async (code: string): Promise => { + const authUrl = `${this.apiUrl}/oauth/token` + try { + // encode in base 64 + const encoded = Buffer.from( + `${env.notion.clientId}:${env.notion.clientSecret}` + ).toString('base64') + + const response = await axios.post<{ access_token: string }>( + authUrl, + { + grant_type: 'authorization_code', + code, + }, + { + headers: { + authorization: `Basic ${encoded}`, + ...this.headers, + }, + timeout: 5000, // 5 seconds + } + ) + return response.data.access_token + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error(error.response) + } else { + logger.error(error) + } + return null + } + } + + async auth(state: string): Promise { + return Promise.resolve(env.notion.authUrl) + } + + export = () => { + throw new Error('Method not implemented.') + } +} diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 517d3befa..661d1ba0d 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -39,4 +39,27 @@ export class PocketClient implements IntegrationClient { export = async (): Promise => { return Promise.resolve(false) } + + async auth(state: string) { + const consumerKey = env.pocket.consumerKey + const redirectUri = `${env.client.url}/settings/integrations` + + // make a POST request to Pocket to get a request token + const response = await axios.post<{ code: string }>( + `${this.apiUrl}/oauth/request`, + { + consumer_key: consumerKey, + redirect_uri: redirectUri, + }, + { + headers: this.headers, + timeout: 5000, // 5 seconds + } + ) + const { code } = response.data + + return `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( + `?pocketToken=${code}&state=${state}` + )}` + } } diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index ae23810bd..e82597a9c 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -57,17 +57,23 @@ export class ReadwiseClient implements IntegrationClient { export = async (token: string, items: LibraryItem[]): Promise => { let result = true - const highlights = items.flatMap(this.itemToReadwiseHighlight) + const highlights = items.flatMap(this._itemToReadwiseHighlight) // If there are no highlights, we will skip the sync if (highlights.length > 0) { - result = await this.syncWithReadwise(token, highlights) + result = await this._syncWithReadwise(token, highlights) } return result } - itemToReadwiseHighlight = (item: LibraryItem): ReadwiseHighlight[] => { + auth(state: string): Promise { + throw new Error('Method not implemented.') + } + + private _itemToReadwiseHighlight = ( + item: LibraryItem + ): ReadwiseHighlight[] => { const category = item.siteName === 'Twitter' ? 'tweets' : 'articles' return item.highlights ?.map((highlight) => { @@ -93,7 +99,7 @@ export class ReadwiseClient implements IntegrationClient { .filter((highlight) => highlight !== undefined) as ReadwiseHighlight[] } - syncWithReadwise = async ( + private _syncWithReadwise = async ( token: string, highlights: ReadwiseHighlight[] ): Promise => { diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index b03a19c15..d42f84ce1 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -113,6 +113,11 @@ export interface BackendEnv { mq: redisConfig cache: redisConfig } + notion: { + clientId: string + clientSecret: string + authUrl: string + } } const nullableEnvVars = [ @@ -165,6 +170,9 @@ const nullableEnvVars = [ 'MQ_REDIS_CERT', 'IMPORTER_METRICS_COLLECTOR_URL', 'INTERNAL_API_URL', + 'NOTION_CLIENT_ID', + 'NOTION_CLIENT_SECRET', + 'NOTION_AUTH_URL', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -311,6 +319,11 @@ export function getEnv(): BackendEnv { cert: parse('REDIS_CERT')?.replace(/\\n/g, '\n'), // replace \n with new line }, } + const notion = { + clientId: parse('NOTION_CLIENT_ID'), + clientSecret: parse('NOTION_CLIENT_SECRET'), + authUrl: parse('NOTION_AUTH_URL'), + } return { pg, @@ -333,6 +346,7 @@ export function getEnv(): BackendEnv { pocket, subscription, redis, + notion, } }