diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index c4d2bbc88..898aca91e 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -25,7 +25,7 @@ export class Integration { @Column('enum', { enum: IntegrationType }) type!: IntegrationType - @Column('varchar') + @Column('varchar', { length: 255 }) token!: string @Column('boolean', { default: true }) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 3a8ca3dc9..74a512f85 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -331,30 +331,6 @@ export type CreateHighlightSuccess = { highlight: Highlight; }; -export type CreateIntegrationError = { - __typename?: 'CreateIntegrationError'; - errorCodes: Array; -}; - -export enum CreateIntegrationErrorCode { - BadRequest = 'BAD_REQUEST', - InvalidToken = 'INVALID_TOKEN', - NotFound = 'NOT_FOUND', - Unauthorized = 'UNAUTHORIZED' -} - -export type CreateIntegrationInput = { - token: Scalars['String']; - type: IntegrationType; -}; - -export type CreateIntegrationResult = CreateIntegrationError | CreateIntegrationSuccess; - -export type CreateIntegrationSuccess = { - __typename?: 'CreateIntegrationSuccess'; - integration: Integration; -}; - export type CreateLabelError = { __typename?: 'CreateLabelError'; errorCodes: Array; @@ -774,6 +750,7 @@ export type Integration = { createdAt: Scalars['Date']; enabled: Scalars['Boolean']; id: Scalars['ID']; + token: Scalars['String']; type: IntegrationType; updatedAt: Scalars['Date']; }; @@ -934,7 +911,6 @@ export type Mutation = { createArticleSavingRequest: CreateArticleSavingRequestResult; createHighlight: CreateHighlightResult; createHighlightReply: CreateHighlightReplyResult; - createIntegration: CreateIntegrationResult; createLabel: CreateLabelResult; createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; @@ -962,6 +938,7 @@ export type Mutation = { setBookmarkArticle: SetBookmarkArticleResult; setDeviceToken: SetDeviceTokenResult; setFollow: SetFollowResult; + setIntegration: SetIntegrationResult; setLabels: SetLabelsResult; setLabelsForHighlight: SetLabelsResult; setLinkArchived: ArchiveLinkResult; @@ -1009,11 +986,6 @@ export type MutationCreateHighlightReplyArgs = { }; -export type MutationCreateIntegrationArgs = { - input: CreateIntegrationInput; -}; - - export type MutationCreateLabelArgs = { input: CreateLabelInput; }; @@ -1139,6 +1111,11 @@ export type MutationSetFollowArgs = { }; +export type MutationSetIntegrationArgs = { + input: SetIntegrationInput; +}; + + export type MutationSetLabelsArgs = { input: SetLabelsInput; }; @@ -1737,6 +1714,33 @@ export type SetFollowSuccess = { updatedUser: User; }; +export type SetIntegrationError = { + __typename?: 'SetIntegrationError'; + errorCodes: Array; +}; + +export enum SetIntegrationErrorCode { + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + InvalidToken = 'INVALID_TOKEN', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type SetIntegrationInput = { + enabled?: InputMaybe; + id?: InputMaybe; + token: Scalars['String']; + type: IntegrationType; +}; + +export type SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess; + +export type SetIntegrationSuccess = { + __typename?: 'SetIntegrationSuccess'; + integration: Integration; +}; + export type SetLabelsError = { __typename?: 'SetLabelsError'; errorCodes: Array; @@ -2548,11 +2552,6 @@ export type ResolversTypes = { CreateHighlightReplySuccess: ResolverTypeWrapper; CreateHighlightResult: ResolversTypes['CreateHighlightError'] | ResolversTypes['CreateHighlightSuccess']; CreateHighlightSuccess: ResolverTypeWrapper; - CreateIntegrationError: ResolverTypeWrapper; - CreateIntegrationErrorCode: CreateIntegrationErrorCode; - CreateIntegrationInput: CreateIntegrationInput; - CreateIntegrationResult: ResolversTypes['CreateIntegrationError'] | ResolversTypes['CreateIntegrationSuccess']; - CreateIntegrationSuccess: ResolverTypeWrapper; CreateLabelError: ResolverTypeWrapper; CreateLabelErrorCode: CreateLabelErrorCode; CreateLabelInput: CreateLabelInput; @@ -2732,6 +2731,11 @@ export type ResolversTypes = { SetFollowInput: SetFollowInput; SetFollowResult: ResolversTypes['SetFollowError'] | ResolversTypes['SetFollowSuccess']; SetFollowSuccess: ResolverTypeWrapper; + SetIntegrationError: ResolverTypeWrapper; + SetIntegrationErrorCode: SetIntegrationErrorCode; + SetIntegrationInput: SetIntegrationInput; + SetIntegrationResult: ResolversTypes['SetIntegrationError'] | ResolversTypes['SetIntegrationSuccess']; + SetIntegrationSuccess: ResolverTypeWrapper; SetLabelsError: ResolverTypeWrapper; SetLabelsErrorCode: SetLabelsErrorCode; SetLabelsForHighlightInput: SetLabelsForHighlightInput; @@ -2909,10 +2913,6 @@ export type ResolversParentTypes = { CreateHighlightReplySuccess: CreateHighlightReplySuccess; CreateHighlightResult: ResolversParentTypes['CreateHighlightError'] | ResolversParentTypes['CreateHighlightSuccess']; CreateHighlightSuccess: CreateHighlightSuccess; - CreateIntegrationError: CreateIntegrationError; - CreateIntegrationInput: CreateIntegrationInput; - CreateIntegrationResult: ResolversParentTypes['CreateIntegrationError'] | ResolversParentTypes['CreateIntegrationSuccess']; - CreateIntegrationSuccess: CreateIntegrationSuccess; CreateLabelError: CreateLabelError; CreateLabelInput: CreateLabelInput; CreateLabelResult: ResolversParentTypes['CreateLabelError'] | ResolversParentTypes['CreateLabelSuccess']; @@ -3056,6 +3056,10 @@ export type ResolversParentTypes = { SetFollowInput: SetFollowInput; SetFollowResult: ResolversParentTypes['SetFollowError'] | ResolversParentTypes['SetFollowSuccess']; SetFollowSuccess: SetFollowSuccess; + SetIntegrationError: SetIntegrationError; + SetIntegrationInput: SetIntegrationInput; + SetIntegrationResult: ResolversParentTypes['SetIntegrationError'] | ResolversParentTypes['SetIntegrationSuccess']; + SetIntegrationSuccess: SetIntegrationSuccess; SetLabelsError: SetLabelsError; SetLabelsForHighlightInput: SetLabelsForHighlightInput; SetLabelsInput: SetLabelsInput; @@ -3382,20 +3386,6 @@ export type CreateHighlightSuccessResolvers; }; -export type CreateIntegrationErrorResolvers = { - errorCodes?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type CreateIntegrationResultResolvers = { - __resolveType: TypeResolveFn<'CreateIntegrationError' | 'CreateIntegrationSuccess', ParentType, ContextType>; -}; - -export type CreateIntegrationSuccessResolvers = { - integration?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export type CreateLabelErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -3717,6 +3707,7 @@ export type IntegrationResolvers; enabled?: Resolver; id?: Resolver; + token?: Resolver; type?: Resolver; updatedAt?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -3833,7 +3824,6 @@ export type MutationResolvers>; createHighlight?: Resolver>; createHighlightReply?: Resolver>; - createIntegration?: Resolver>; createLabel?: Resolver>; createNewsletterEmail?: Resolver; createReaction?: Resolver>; @@ -3861,6 +3851,7 @@ export type MutationResolvers>; setDeviceToken?: Resolver>; setFollow?: Resolver>; + setIntegration?: Resolver>; setLabels?: Resolver>; setLabelsForHighlight?: Resolver>; setLinkArchived?: Resolver>; @@ -4167,6 +4158,20 @@ export type SetFollowSuccessResolvers; }; +export type SetIntegrationErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SetIntegrationResultResolvers = { + __resolveType: TypeResolveFn<'SetIntegrationError' | 'SetIntegrationSuccess', ParentType, ContextType>; +}; + +export type SetIntegrationSuccessResolvers = { + integration?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SetLabelsErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4635,9 +4640,6 @@ export type Resolvers = { CreateHighlightReplySuccess?: CreateHighlightReplySuccessResolvers; CreateHighlightResult?: CreateHighlightResultResolvers; CreateHighlightSuccess?: CreateHighlightSuccessResolvers; - CreateIntegrationError?: CreateIntegrationErrorResolvers; - CreateIntegrationResult?: CreateIntegrationResultResolvers; - CreateIntegrationSuccess?: CreateIntegrationSuccessResolvers; CreateLabelError?: CreateLabelErrorResolvers; CreateLabelResult?: CreateLabelResultResolvers; CreateLabelSuccess?: CreateLabelSuccessResolvers; @@ -4760,6 +4762,9 @@ export type Resolvers = { SetFollowError?: SetFollowErrorResolvers; SetFollowResult?: SetFollowResultResolvers; SetFollowSuccess?: SetFollowSuccessResolvers; + SetIntegrationError?: SetIntegrationErrorResolvers; + SetIntegrationResult?: SetIntegrationResultResolvers; + SetIntegrationSuccess?: SetIntegrationSuccessResolvers; SetLabelsError?: SetLabelsErrorResolvers; SetLabelsResult?: SetLabelsResultResolvers; SetLabelsSuccess?: SetLabelsSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 3f6bef289..af1193142 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -284,28 +284,6 @@ type CreateHighlightSuccess { highlight: Highlight! } -type CreateIntegrationError { - errorCodes: [CreateIntegrationErrorCode!]! -} - -enum CreateIntegrationErrorCode { - BAD_REQUEST - INVALID_TOKEN - NOT_FOUND - UNAUTHORIZED -} - -input CreateIntegrationInput { - token: String! - type: IntegrationType! -} - -union CreateIntegrationResult = CreateIntegrationError | CreateIntegrationSuccess - -type CreateIntegrationSuccess { - integration: Integration! -} - type CreateLabelError { errorCodes: [CreateLabelErrorCode!]! } @@ -684,6 +662,7 @@ type Integration { createdAt: Date! enabled: Boolean! id: ID! + token: String! type: IntegrationType! updatedAt: Date! } @@ -830,7 +809,6 @@ type Mutation { createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! createHighlight(input: CreateHighlightInput!): CreateHighlightResult! createHighlightReply(input: CreateHighlightReplyInput!): CreateHighlightReplyResult! - createIntegration(input: CreateIntegrationInput!): CreateIntegrationResult! createLabel(input: CreateLabelInput!): CreateLabelResult! createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! @@ -858,6 +836,7 @@ type Mutation { setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult! setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult! setFollow(input: SetFollowInput!): SetFollowResult! + setIntegration(input: SetIntegrationInput!): SetIntegrationResult! setLabels(input: SetLabelsInput!): SetLabelsResult! setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult! setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult! @@ -1260,6 +1239,31 @@ type SetFollowSuccess { updatedUser: User! } +type SetIntegrationError { + errorCodes: [SetIntegrationErrorCode!]! +} + +enum SetIntegrationErrorCode { + ALREADY_EXISTS + BAD_REQUEST + INVALID_TOKEN + NOT_FOUND + UNAUTHORIZED +} + +input SetIntegrationInput { + enabled: Boolean + id: ID + token: String! + type: IntegrationType! +} + +union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess + +type SetIntegrationSuccess { + integration: Integration! +} + type SetLabelsError { errorCodes: [SetLabelsErrorCode!]! } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 686d31a5a..400fe1632 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -97,6 +97,7 @@ import { generateUploadFilePathName, } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' +import { setIntegrationResolver } from './integrations' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -165,6 +166,7 @@ export const functionResolvers = { revokeApiKey: revokeApiKeyResolver, setLabelsForHighlight: setLabelsForHighlightResolver, moveLabel: moveLabelResolver, + setIntegration: setIntegrationResolver, }, Query: { me: getMeUserResolver, @@ -592,4 +594,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('TypeaheadSearch'), ...resultResolveTypeResolver('UpdatesSince'), ...resultResolveTypeResolver('MoveLabel'), + ...resultResolveTypeResolver('SetIntegration'), } diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts new file mode 100644 index 000000000..42bb0a405 --- /dev/null +++ b/packages/api/src/resolvers/integrations/index.ts @@ -0,0 +1,97 @@ +import { authorized } from '../../utils/helpers' +import { + MutationSetIntegrationArgs, + SetIntegrationError, + SetIntegrationErrorCode, + SetIntegrationSuccess, +} from '../../generated/graphql' +import { getRepository } from '../../entity/utils' +import { User } from '../../entity/user' +import { Integration } from '../../entity/integration' +import { analytics } from '../../utils/analytics' +import { env } from '../../env' +import { validateToken } from '../../services/integrations' + +export const setIntegrationResolver = authorized< + SetIntegrationSuccess, + SetIntegrationError, + MutationSetIntegrationArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('setIntegrationResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [SetIntegrationErrorCode.Unauthorized], + } + } + + const integrationToSave: Partial = { + user, + token: input.token, + type: input.type, + enabled: input.enabled === null ? true : input.enabled, + } + + if (input.id) { + // Update + const existingIntegration = await getRepository(Integration).findOne({ + where: { id: input.id }, + relations: ['user'], + }) + if (!existingIntegration) { + return { + errorCodes: [SetIntegrationErrorCode.NotFound], + } + } + if (existingIntegration.user.id !== uid) { + return { + errorCodes: [SetIntegrationErrorCode.Unauthorized], + } + } + + integrationToSave.id = input.id + } else { + // Create + const existingIntegration = await getRepository(Integration).findOneBy({ + user: { id: uid }, + type: input.type, + }) + + if (existingIntegration) { + return { + errorCodes: [SetIntegrationErrorCode.AlreadyExists], + } + } + + // validate token + if (!(await validateToken(input.token, input.type))) { + return { + errorCodes: [SetIntegrationErrorCode.InvalidToken], + } + } + } + + const integration = await getRepository(Integration).save(integrationToSave) + + analytics.track({ + userId: uid, + event: 'integration_set', + properties: { + id: integration.id, + env: env.server.apiEnv, + }, + }) + + return { + integration, + } + } catch (error) { + log.error(error) + + return { + errorCodes: [SetIntegrationErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index a1991f77e..956312181 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1812,17 +1812,16 @@ const schema = gql` NOT_FOUND } - union CreateIntegrationResult = - CreateIntegrationSuccess - | CreateIntegrationError + union SetIntegrationResult = SetIntegrationSuccess | SetIntegrationError - type CreateIntegrationSuccess { + type SetIntegrationSuccess { integration: Integration! } type Integration { id: ID! type: IntegrationType! + token: String! enabled: Boolean! createdAt: Date! updatedAt: Date! @@ -1832,20 +1831,23 @@ const schema = gql` READWISE } - type CreateIntegrationError { - errorCodes: [CreateIntegrationErrorCode!]! + type SetIntegrationError { + errorCodes: [SetIntegrationErrorCode!]! } - enum CreateIntegrationErrorCode { + enum SetIntegrationErrorCode { UNAUTHORIZED BAD_REQUEST NOT_FOUND INVALID_TOKEN + ALREADY_EXISTS } - input CreateIntegrationInput { + input SetIntegrationInput { + id: ID type: IntegrationType! token: String! + enabled: Boolean } # Mutations @@ -1917,7 +1919,7 @@ const schema = gql` revokeApiKey(id: ID!): RevokeApiKeyResult! setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult! moveLabel(input: MoveLabelInput!): MoveLabelResult! - createIntegration(input: CreateIntegrationInput!): CreateIntegrationResult! + setIntegration(input: SetIntegrationInput!): SetIntegrationResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed diff --git a/packages/api/src/services/integrations.ts b/packages/api/src/services/integrations.ts new file mode 100644 index 000000000..01065f371 --- /dev/null +++ b/packages/api/src/services/integrations.ts @@ -0,0 +1,27 @@ +import { IntegrationType } from '../generated/graphql' +import { env } from '../env' +import axios from 'axios' + +const READWISE_API_URL = 'https://readwise.io/api/v2' + +export const validateToken = async ( + token: string, + type: IntegrationType +): Promise => { + switch (type) { + case IntegrationType.Readwise: + return validateReadwiseToken(token) + default: + return false + } +} + +const validateReadwiseToken = async (token: string): Promise => { + const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth` + const response = await axios.get(authUrl, { + headers: { + Authorization: `Token ${token}`, + }, + }) + return response.status === 204 +} diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index d1c6d9fd4..bbd221f12 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -84,6 +84,9 @@ interface BackendEnv { resetPasswordTemplateId: string installationTemplateId: string } + readwise: { + apiUrl: string + } } /*** @@ -132,6 +135,7 @@ const nullableEnvVars = [ 'SENDGRID_REMINDER_TEMPLATE_ID', 'SENDGRID_RESET_PASSWORD_TEMPLATE_ID', 'SENDGRID_INSTALLATION_TEMPLATE_ID', + 'READWISE_API_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 */ @@ -245,6 +249,10 @@ export function getEnv(): BackendEnv { installationTemplateId: parse('SENDGRID_INSTALLATION_TEMPLATE_ID'), } + const readwise = { + apiUrl: parse('READWISE_API_URL'), + } + return { pg, client, @@ -262,6 +270,7 @@ export function getEnv(): BackendEnv { elastic, sender, sendgrid, + readwise, } }