remove unused code

This commit is contained in:
Hongbo Wu 2023-10-27 12:10:59 +08:00
parent 5cc9474b73
commit 07eb97e7cc
20 changed files with 220 additions and 953 deletions

View file

@ -18,10 +18,11 @@ import {
SetIntegrationErrorCode,
SetIntegrationSuccess,
} from '../../generated/graphql'
import { createIntegrationToken } from '../../routers/auth/jwt_helpers'
import {
findIntegration,
findIntegrations,
getIntegrationService,
getIntegrationClient,
removeIntegration,
saveIntegration,
updateIntegration,
@ -29,8 +30,8 @@ import {
import { analytics } from '../../utils/analytics'
import {
deleteTask,
enqueueExportToIntegration,
enqueueImportFromIntegration,
enqueueSyncWithIntegration,
} from '../../utils/createTask'
import { authorized } from '../../utils/helpers'
@ -60,7 +61,7 @@ export const setIntegrationResolver = authorized<
integrationToSave.taskName = existingIntegration.taskName
} else {
// Create
const integrationService = getIntegrationService(input.name)
const integrationService = getIntegrationClient(input.name)
// authorize and get access token
const token = await integrationService.accessToken(input.token)
if (!token) {
@ -74,12 +75,27 @@ export const setIntegrationResolver = authorized<
// save integration
const integration = await saveIntegration(integrationToSave, uid)
if (
integrationToSave.type === IntegrationType.Export &&
(!integrationToSave.id || integrationToSave.enabled)
) {
if (integrationToSave.type === IntegrationType.Export && !input.id) {
const authToken = await createIntegrationToken({
uid,
token: integration.token,
})
if (!authToken) {
log.error('failed to create auth token', {
integrationId: integration.id,
})
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
// create a task to sync all the pages if new integration or enable integration (export type)
const taskName = await enqueueSyncWithIntegration(uid, input.name)
const taskName = await enqueueExportToIntegration(
integration.id,
integration.name,
0,
authToken
)
log.info('enqueued task', taskName)
// update task name in integration
@ -191,7 +207,7 @@ export const importFromIntegrationResolver = authorized<
ImportFromIntegrationSuccess,
ImportFromIntegrationError,
MutationImportFromIntegrationArgs
>(async (_, { integrationId }, { claims: { uid }, log, signToken }) => {
>(async (_, { integrationId }, { claims: { uid }, log }) => {
log.info('importFromIntegrationResolver')
try {
@ -203,14 +219,21 @@ export const importFromIntegrationResolver = authorized<
}
}
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
const authToken = (await signToken(
{ uid, exp },
env.server.jwtSecret
)) as string
const authToken = await createIntegrationToken({
uid: integration.user.id,
token: integration.token,
})
if (!authToken) {
return {
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
}
}
// create a task to import all the pages
const taskName = await enqueueImportFromIntegration(
integration.id,
integration.name,
integration.syncedAt?.getTime() || 0,
authToken
)
// update task name in integration

View file

@ -40,3 +40,8 @@ export function isPendingUserTokenPayload(
'username' in object
)
}
export type IntegrationTokenPayload = {
uid: string
token: string
}

View file

@ -4,6 +4,7 @@ import { promisify } from 'util'
import { env } from '../../env'
import { logger } from '../../utils/logger'
import {
IntegrationTokenPayload,
isPendingUserTokenPayload,
PendingUserTokenPayload,
} from './auth_types'
@ -86,11 +87,19 @@ export function suggestedUsername(name: string): string {
return `${prefix}${suffix}`
}
export async function createWebAuthTokenWithPayload(
payload: Record<string, unknown>
export async function createIntegrationToken(
payload: IntegrationTokenPayload
): Promise<string | undefined> {
try {
const authToken = await signToken(payload, env.server.jwtSecret)
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
const authToken = await signToken(
{
...payload,
exp,
},
env.server.jwtSecret
)
logger.info('createIntegrationToken', payload)
return authToken as string
} catch {
return undefined

View file

@ -1,45 +1,13 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { stringify } from 'csv-stringify'
import express from 'express'
import { DateTime } from 'luxon'
import { v4 as uuidv4 } from 'uuid'
import { Integration, IntegrationType } from '../../entity/integration'
import { LibraryItem } from '../../entity/library_item'
import { EntityType, readPushSubscription } from '../../pubsub'
import { readPushSubscription } from '../../pubsub'
import { getRepository } from '../../repository'
import { Claims } from '../../resolvers/types'
import {
findIntegration,
getIntegrationService,
updateIntegration,
} from '../../services/integrations'
import {
findLibraryItemById,
searchLibraryItems,
} from '../../services/library_item'
import { getClaimsByToken } from '../../utils/auth'
import { enqueueExportToIntegration } from '../../utils/createTask'
import { logger } from '../../utils/logger'
import { DateFilter } from '../../utils/search'
import { createGCSFile } from '../../utils/uploads'
import { createWebAuthTokenWithPayload } from '../auth/jwt_helpers'
export interface Message {
type?: EntityType
id?: string
userId: string
pageId?: string
articleId?: string
}
interface ImportEvent {
integrationId: string
}
const isImportEvent = (event: any): event is ImportEvent =>
'integrationId' in event
import { createIntegrationToken } from '../auth/jwt_helpers'
export function integrationsServiceRouter() {
const router = express.Router()
@ -70,10 +38,8 @@ export function integrationsServiceRouter() {
// create a task to sync with each integration
await Promise.all(
integrations.map(async (integration) => {
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
const authToken = await createWebAuthTokenWithPayload({
const authToken = await createIntegrationToken({
uid: integration.user.id,
exp,
token: integration.token,
})
@ -101,260 +67,5 @@ export function integrationsServiceRouter() {
res.status(200).send('OK')
})
router.post('/:integrationName/:action', async (req, res) => {
logger.info('start to sync with integration', {
action: req.params.action,
integrationName: req.params.integrationName,
})
try {
const { message: msgStr, expired } = readPushSubscription(req)
if (!msgStr) {
return res.status(200).send('Bad Request')
}
if (expired) {
logger.info('discarding expired message')
return res.status(200).send('Expired')
}
const data: Message = JSON.parse(msgStr)
const userId = data.userId
const type = data.type
if (!userId) {
logger.info('No userId found in message')
res.status(200).send('Bad Request')
return
}
const integration = await findIntegration(
{
name: req.params.integrationName.toUpperCase(),
type: IntegrationType.Export,
enabled: true,
},
userId
)
if (!integration) {
logger.info('No active integration found for user', { userId })
res.status(200).send('No integration found')
return
}
const action = req.params.action.toUpperCase()
const integrationService = getIntegrationService(integration.name)
if (action === 'SYNC_UPDATED') {
// get updated page by id
let id: string | undefined
switch (type) {
case EntityType.PAGE:
id = data.id
break
case EntityType.HIGHLIGHT:
id = data.articleId
break
case EntityType.LABEL:
id = data.pageId
break
}
if (!id) {
logger.info('No id found in message')
res.status(200).send('Bad Request')
return
}
const item = await findLibraryItemById(id, userId)
if (!item) {
logger.info('No item found for id', { id })
res.status(200).send('No page found')
return
}
// sync updated item with integration
logger.info('syncing updated item with integration', {
integrationId: integration.id,
itemId: item.id,
})
const synced = await integrationService.export(integration, [item])
if (!synced) {
logger.info('failed to sync item', {
integrationId: integration.id,
itemId: item.id,
})
return res.status(400).send('Failed to sync')
}
} else if (action === 'SYNC_ALL') {
// sync all pages of the user
const size = 50
for (
let hasNextPage = true,
count = 0,
after = 0,
items: LibraryItem[] = [];
hasNextPage;
after += size, hasNextPage = count > after
) {
const syncedAt = integration.syncedAt
// only sync pages that were updated after syncedAt
const dateFilters: DateFilter[] = []
syncedAt &&
dateFilters.push({ field: 'updatedAt', startDate: syncedAt })
const { libraryItems } = await searchLibraryItems(
{ from: after, size, dateFilters },
userId
)
items = libraryItems
const itemIds = items.map((p) => p.id)
logger.info('syncing items', { pageIds: itemIds })
const synced = await integrationService.export(integration, items)
if (!synced) {
logger.error('failed to sync items', {
pageIds: itemIds,
integrationId: integration.id,
})
return res.status(400).send('Failed to sync')
}
}
// delete task name if completed
await updateIntegration(
integration.id,
{
taskName: null,
},
userId
)
} else {
logger.info('unknown action', { action })
res.status(200).send('Unknown action')
return
}
} catch (err) {
logger.error('sync with integrations failed', err)
return res.status(500).send(err)
}
res.status(200).send('OK')
})
// import pages from integration task handler
router.post('/import', async (req, res) => {
logger.info('start cloud task to import pages from integration')
const token = req.cookies?.auth || req.headers?.authorization
let claims: Claims | undefined
try {
claims = await getClaimsByToken(token)
if (!claims) {
return res.status(401).send('UNAUTHORIZED')
}
} catch (err) {
logger.error('failed to get claims from token', err)
return res.status(401).send('UNAUTHORIZED')
}
if (!isImportEvent(req.body)) {
logger.info('Invalid message')
return res.status(400).send('Bad Request')
}
let writeStream: NodeJS.WritableStream | undefined
try {
const userId = claims.uid
const integration = await findIntegration(
{
id: req.body.integrationId,
enabled: true,
type: IntegrationType.Import,
},
userId
)
if (!integration) {
logger.info('No active integration found for user', { userId })
return res.status(200).send('No integration found')
}
const integrationService = getIntegrationService(integration.name)
// import pages from integration
logger.info('importing pages from integration', {
integrationId: integration.id,
})
let offset = 0
const since = integration.syncedAt?.getTime() || 0
let syncedAt = since
// get pages from integration
const retrieved = await integrationService.retrieve({
token: integration.token,
since,
offset,
})
syncedAt = retrieved.since || Date.now()
let retrievedData = retrieved.data
// if there are pages to import
if (retrievedData.length > 0) {
// write the list of urls to a csv file and upload it to gcs
// path style: imports/<uid>/<date>/<type>-<uuid>.csv
const dateStr = DateTime.now().toISODate()
const fileUuid = uuidv4()
const fullPath = `imports/${userId}/${dateStr}/${integrationService.name}-${fileUuid}.csv`
// open a write_stream to the file
const file = createGCSFile(fullPath)
writeStream = file.createWriteStream({
contentType: 'text/csv',
})
// stringify the data and pipe it to the write_stream
const stringifier = stringify({
header: true,
columns: ['url', 'state', 'labels'],
})
stringifier.pipe(writeStream)
// paginate api calls to the integration
do {
// write the list of urls, state and labels to the stream
retrievedData.forEach((row) => stringifier.write(row))
// get next pages from the integration
offset += retrievedData.length
const retrieved = await integrationService.retrieve({
token: integration.token,
since,
offset,
})
syncedAt = retrieved.since || Date.now()
retrievedData = retrieved.data
logger.info('retrieved data', {
total: offset,
size: retrievedData.length,
})
} while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages
}
// update the integration's syncedAt and remove taskName
await updateIntegration(
integration.id,
{
syncedAt: new Date(syncedAt),
taskName: null,
},
userId
)
} catch (err) {
logger.error('import pages from integration failed', err)
return res.status(500).send(err)
} finally {
writeStream?.end()
}
res.status(200).send('OK')
})
return router
}

View file

@ -1,19 +1,19 @@
import { DeepPartial, FindOptionsWhere } from 'typeorm'
import { Integration } from '../../entity/integration'
import { authTrx } from '../../repository'
import { IntegrationService } from './integration'
import { PocketIntegration } from './pocket'
import { ReadwiseIntegration } from './readwise'
import { IntegrationClient } from './integration'
import { PocketClient } from './pocket'
import { ReadwiseClient } from './readwise'
const integrations: IntegrationService[] = [
new ReadwiseIntegration(),
new PocketIntegration(),
const integrations: IntegrationClient[] = [
new ReadwiseClient(),
new PocketClient(),
]
export const getIntegrationService = (name: string): IntegrationService => {
export const getIntegrationClient = (name: string): IntegrationClient => {
const service = integrations.find((s) => s.name === name)
if (!service) {
throw new Error(`Integration service not found: ${name}`)
throw new Error(`Integration client not found: ${name}`)
}
return service
}

View file

@ -1,5 +1,4 @@
import { Integration } from '../../entity/integration'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { LibraryItemState } from '../../entity/library_item'
export interface RetrievedData {
url: string
@ -19,19 +18,9 @@ export interface RetrieveRequest {
offset?: number
}
export abstract class IntegrationService {
abstract name: string
export interface IntegrationClient {
name: string
apiUrl: string
accessToken = async (token: string): Promise<string | null> => {
return Promise.resolve(null)
}
export = async (
integration: Integration,
items: LibraryItem[]
): Promise<boolean> => {
return Promise.resolve(false)
}
retrieve = async (req: RetrieveRequest): Promise<RetrievedResult> => {
return Promise.resolve({ data: [] })
}
accessToken(token: string): Promise<string | null>
}

View file

@ -3,7 +3,7 @@ import { LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import { logger } from '../../utils/logger'
import {
IntegrationService,
IntegrationClient,
RetrievedResult,
RetrieveRequest,
} from './integration'
@ -51,16 +51,16 @@ interface Author {
name: string
}
export class PocketIntegration extends IntegrationService {
export class PocketClient implements IntegrationClient {
name = 'POCKET'
POCKET_API_URL = 'https://getpocket.com/v3'
apiUrl = 'https://getpocket.com/v3'
headers = {
'Content-Type': 'application/json',
'X-Accept': 'application/json',
}
accessToken = async (token: string): Promise<string | null> => {
const url = `${this.POCKET_API_URL}/oauth/authorize`
const url = `${this.apiUrl}/oauth/authorize`
try {
const response = await axios.post<{ access_token: string }>(
url,
@ -90,7 +90,7 @@ export class PocketIntegration extends IntegrationService {
count = 100,
offset = 0
): Promise<PocketResponse | null> => {
const url = `${this.POCKET_API_URL}/get`
const url = `${this.apiUrl}/get`
try {
const response = await axios.post<PocketResponse>(
url,

View file

@ -1,13 +1,6 @@
import axios from 'axios'
import { updateIntegration } from '.'
import { HighlightType } from '../../entity/highlight'
import { Integration } from '../../entity/integration'
import { LibraryItem } from '../../entity/library_item'
import { env } from '../../env'
import { wait } from '../../utils/helpers'
import { logger } from '../../utils/logger'
import { findHighlightsByLibraryItemId, getHighlightUrl } from '../highlights'
import { IntegrationService } from './integration'
import { IntegrationClient } from './integration'
interface ReadwiseHighlight {
// The highlight text, (technically the only field required in a highlight object)
@ -36,12 +29,12 @@ interface ReadwiseHighlight {
highlight_url?: string
}
export const READWISE_API_URL = 'https://readwise.io/api/v2'
export class ReadwiseIntegration extends IntegrationService {
export class ReadwiseClient implements IntegrationClient {
name = 'READWISE'
apiUrl = 'https://readwise.io/api/v2'
accessToken = async (token: string): Promise<string | null> => {
const authUrl = `${env.readwise.apiUrl || READWISE_API_URL}/auth`
const authUrl = `${this.apiUrl}/auth`
try {
const response = await axios.get(authUrl, {
headers: {
@ -58,110 +51,4 @@ export class ReadwiseIntegration extends IntegrationService {
return null
}
}
export = async (
integration: Integration,
items: LibraryItem[]
): Promise<boolean> => {
let result = true
const highlights = await Promise.all(
items.map((item) =>
this.libraryItemToReadwiseHighlight(item, integration.user.id)
)
)
// If there are no highlights, we will skip the sync
if (highlights.length > 0) {
result = await this.syncWithReadwise(integration.token, highlights.flat())
}
// update integration syncedAt if successful
if (result) {
logger.info('updating integration syncedAt')
await updateIntegration(
integration.id,
{
syncedAt: new Date(),
},
integration.user.id
)
}
return result
}
libraryItemToReadwiseHighlight = async (
item: LibraryItem,
userId: string
): Promise<ReadwiseHighlight[]> => {
let highlights = item.highlights
if (!highlights) {
highlights = await findHighlightsByLibraryItemId(item.id, userId)
}
const category = item.siteName === 'Twitter' ? 'tweets' : 'articles'
return highlights
.map((highlight) => {
// filter out highlights that are not of type highlight or have no quote
if (
highlight.highlightType !== HighlightType.Highlight ||
!highlight.quote
) {
return undefined
}
return {
text: highlight.quote,
title: item.title,
author: item.author || undefined,
highlight_url: getHighlightUrl(item.slug, highlight.id),
highlighted_at: new Date(highlight.createdAt).toISOString(),
category,
image_url: item.thumbnail || undefined,
// location: highlight.highlightPositionAnchorIndex || undefined,
location_type: 'order',
note: highlight.annotation || undefined,
source_type: 'omnivore',
source_url: item.originalUrl,
}
})
.filter((highlight) => highlight !== undefined) as ReadwiseHighlight[]
}
syncWithReadwise = async (
token: string,
highlights: ReadwiseHighlight[],
retryCount = 0
): Promise<boolean> => {
const url = `${env.readwise.apiUrl || READWISE_API_URL}/highlights`
try {
const response = await axios.post(
url,
{
highlights,
},
{
headers: {
Authorization: `Token ${token}`,
'Content-Type': 'application/json',
},
timeout: 5000, // 5 seconds
}
)
return response.status === 200
} catch (error) {
logger.error(error)
if (axios.isAxiosError(error)) {
if (error.response?.status === 429 && retryCount < 3) {
logger.info('Readwise API rate limit exceeded, retrying...')
// wait for Retry-After seconds in the header if rate limited
// max retry count is 3
const retryAfter = error.response?.headers['retry-after'] || '10' // default to 10 seconds
await wait(parseInt(retryAfter, 10) * 1000)
return this.syncWithReadwise(token, highlights, retryCount + 1)
}
}
return false
}
}
}

View file

@ -328,47 +328,6 @@ export const enqueueReminder = async (
return createdTasks[0].name
}
export const enqueueSyncWithIntegration = async (
userId: string,
integrationName: string
): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT, PUBSUB_VERIFICATION_TOKEN } = process.env
// use pubsub data format to send the userId to the task handler
const payload = {
message: {
data: Buffer.from(
JSON.stringify({
userId,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
return nanoid()
}
const createdTasks = await createHttpTaskWithToken({
project: GOOGLE_CLOUD_PROJECT,
payload,
taskHandlerUrl: `${
env.queue.integrationTaskHandlerUrl
}/${integrationName.toLowerCase()}/sync_all?token=${PUBSUB_VERIFICATION_TOKEN}`,
priority: 'low',
})
if (!createdTasks || !createdTasks[0].name) {
logger.error(`Unable to get the name of the task`, {
payload,
createdTasks,
})
throw new CreateTaskError(`Unable to get the name of the task`)
}
return createdTasks[0].name
}
export const enqueueTextToSpeech = async ({
userId,
text,
@ -498,23 +457,27 @@ export const enqueueRecommendation = async (
export const enqueueImportFromIntegration = async (
integrationId: string,
integrationName: string,
syncAt: number, // unix timestamp in milliseconds
authToken: string
): Promise<string> => {
const { GOOGLE_CLOUD_PROJECT } = process.env
const payload = {
integrationId,
integrationName,
syncAt,
}
const headers = {
Cookie: `auth=${authToken}`,
[OmnivoreAuthorizationHeader]: authToken,
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
if (env.queue.integrationTaskHandlerUrl) {
if (env.queue.integrationImporterUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(`${env.queue.integrationTaskHandlerUrl}/import`, payload, {
.post(env.queue.integrationImporterUrl, payload, {
headers,
})
.catch((error) => {
@ -528,7 +491,7 @@ export const enqueueImportFromIntegration = async (
const createdTasks = await createHttpTaskWithToken({
project: GOOGLE_CLOUD_PROJECT,
payload,
taskHandlerUrl: `${env.queue.integrationTaskHandlerUrl}/import`,
taskHandlerUrl: env.queue.integrationImporterUrl,
priority: 'low',
requestHeaders: headers,
})
@ -557,15 +520,15 @@ export const enqueueExportToIntegration = async (
}
const headers = {
Cookie: `auth=${authToken}`,
[OmnivoreAuthorizationHeader]: authToken,
}
// If there is no Google Cloud Project Id exposed, it means that we are in local environment
if (env.dev.isLocal || !GOOGLE_CLOUD_PROJECT) {
if (env.queue.integrationTaskHandlerUrl) {
if (env.queue.integrationExporterUrl) {
// Calling the handler function directly.
setTimeout(() => {
axios
.post(`${env.queue.integrationExporterUrl}`, payload, {
.post(env.queue.integrationExporterUrl, payload, {
headers,
})
.catch((error) => {
@ -579,7 +542,7 @@ export const enqueueExportToIntegration = async (
const createdTasks = await createHttpTaskWithToken({
project: GOOGLE_CLOUD_PROJECT,
payload,
taskHandlerUrl: `${env.queue.integrationExporterUrl}`,
taskHandlerUrl: env.queue.integrationExporterUrl,
priority: 'low',
requestHeaders: headers,
})

View file

@ -11,7 +11,6 @@ import {
saveIntegration,
updateIntegration,
} from '../../src/services/integrations'
import { READWISE_API_URL } from '../../src/services/integrations/readwise'
import { deleteUser } from '../../src/services/user'
import { createTestUser } from '../db'
import { generateFakeUuid, graphqlRequest, request } from '../util'
@ -19,6 +18,8 @@ import { generateFakeUuid, graphqlRequest, request } from '../util'
chai.use(sinonChai)
describe('Integrations resolvers', () => {
const READWISE_API_URL = 'https://readwise.io/api/v2'
let loginUser: User
let authToken: string

View file

@ -1,423 +0,0 @@
import { Storage } from '@google-cloud/storage'
import { expect } from 'chai'
import { DateTime } from 'luxon'
import 'mocha'
import nock from 'nock'
import sinon from 'sinon'
import { Highlight } from '../../src/entity/highlight'
import { Integration, IntegrationType } from '../../src/entity/integration'
import { LibraryItem } from '../../src/entity/library_item'
import { User } from '../../src/entity/user'
import { env } from '../../src/env'
import { PubSubRequestBody } from '../../src/pubsub'
import { createHighlight, getHighlightUrl } from '../../src/services/highlights'
import {
deleteIntegrations,
saveIntegration,
updateIntegration,
} from '../../src/services/integrations'
import { READWISE_API_URL } from '../../src/services/integrations/readwise'
import { deleteLibraryItemById } from '../../src/services/library_item'
import { deleteUser } from '../../src/services/user'
import { createTestLibraryItem, createTestUser } from '../db'
import { MockBucket } from '../mock_storage'
import { request } from '../util'
describe('Integrations routers', () => {
const baseUrl = '/svc/pubsub/integrations'
let token: string
let user: User
let authToken: string
before(async () => {
user = await createTestUser('fakeUser')
const res = await request
.post('/local/debug/fake-user-login')
.send({ fakeEmail: user.email })
const body = res.body as { authToken: string }
authToken = body.authToken
})
after(async () => {
await deleteUser(user.id)
})
describe('sync with integrations', () => {
const endpoint = (token: string, name = 'name', action = 'action') =>
`${baseUrl}/${name}/${action}?token=${token}`
let action: string
let data: PubSubRequestBody
let integrationName: string
context('when token is invalid', () => {
before(() => {
token = 'invalid-token'
})
it('returns 200', async () => {
return request.post(endpoint(token)).send(data).expect(200)
})
})
context('when token is valid', () => {
before(() => {
token = process.env.PUBSUB_VERIFICATION_TOKEN as string
})
context('when data is expired', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: 'userId', type: 'page' })
).toString('base64'),
publishTime: DateTime.now().minus({ hours: 12 }).toISO(),
},
}
})
it('returns 200 with Expired', async () => {
const res = await request.post(endpoint(token)).send(data).expect(200)
expect(res.text).to.eql('Expired')
})
})
context('when userId is empty', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: '', type: 'page' })
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
})
it('returns 200', async () => {
return request.post(endpoint(token)).send(data).expect(200)
})
})
context('when user exists', () => {
context('when integration not found', () => {
before(() => {
integrationName = 'READWISE'
data = {
message: {
data: Buffer.from(
JSON.stringify({ userId: user.id, type: 'page' })
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
})
it('returns 200 with No integration found', async () => {
const res = await request
.post(endpoint(token, integrationName))
.send(data)
.expect(200)
expect(res.text).to.eql('No integration found')
})
})
context('when integration is readwise and enabled', () => {
let integration: Integration
let item: LibraryItem
let highlight: Highlight
let highlightsData: any
before(async () => {
integration = await saveIntegration(
{
user,
name: 'READWISE',
token: 'token',
},
user.id
)
integrationName = integration.name
// create page
item = await createTestLibraryItem(user.id)
// create highlight
const highlightPositionPercent = 25
highlight = await createHighlight(
{
patch: 'test patch',
quote: 'test quote',
shortId: 'test shortId',
highlightPositionPercent,
user,
libraryItem: item,
},
item.id,
user.id
)
// create highlights data for integration request
highlightsData = {
highlights: [
{
text: highlight.quote,
title: item.title,
author: item.author ?? undefined,
highlight_url: getHighlightUrl(item.slug, highlight.id),
highlighted_at: highlight.createdAt.toISOString(),
category: 'articles',
image_url: item.thumbnail ?? undefined,
// location: highlightPositionPercent,
location_type: 'order',
note: highlight.annotation ?? undefined,
source_type: 'omnivore',
source_url: item.originalUrl,
},
],
}
})
after(async () => {
await deleteIntegrations(user.id, [integration.id])
await deleteLibraryItemById(item.id)
})
context('when action is sync_updated', () => {
before(() => {
action = 'sync_updated'
})
context('when entity type is page', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
type: 'page',
id: item.id,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
'Content-Type': 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
context('when readwise highlight API reaches rate limits', () => {
before(() => {
// mock Readwise Highlight API with rate limits
// retry after 1 second
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
'Content-Type': 'application/json',
},
})
.post('/highlights')
.reply(429, 'Rate Limited', { 'Retry-After': '1' })
// mock Readwise Highlight API after 1 second
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
'Content-Type': 'application/json',
},
})
.post('/highlights')
.delay(1000)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
context('when entity type is highlight', () => {
before(() => {
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
type: 'highlight',
articleId: item.id,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
'Content-Type': 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
context('when action is sync_all', () => {
before(async () => {
action = 'sync_all'
data = {
message: {
data: Buffer.from(
JSON.stringify({
userId: user.id,
})
).toString('base64'),
publishTime: new Date().toISOString(),
},
}
// mock Readwise Highlight API
nock(READWISE_API_URL, {
reqheaders: {
Authorization: `Token ${integration.token}`,
'Content-Type': 'application/json',
},
})
.post('/highlights', highlightsData)
.reply(200)
await updateIntegration(
integration.id,
{
syncedAt: null,
taskName: 'some task name',
},
user.id
)
})
it('returns 200 with OK', async () => {
const res = await request
.post(endpoint(token, integrationName, action))
.send(data)
.expect(200)
expect(res.text).to.eql('OK')
})
})
})
})
})
})
describe('import from integrations router', () => {
let integration: Integration
before(async () => {
token = 'test token'
// create integration
integration = await saveIntegration(
{
user: { id: user.id },
name: 'POCKET',
token,
type: IntegrationType.Import,
},
user.id
)
// mock Pocket API
const reqBody = {
access_token: token,
consumer_key: env.pocket.consumerKey,
state: 'all',
detailType: 'complete',
since: 0,
sort: 'oldest',
count: 100,
offset: 0,
}
nock('https://getpocket.com', {
reqheaders: {
'content-type': 'application/json',
'x-accept': 'application/json',
},
})
.post('/v3/get', reqBody)
.reply(200, {
complete: 1,
list: {
'123': {
given_url: 'https://omnivore.app/pocket-import-test,test',
state: '0',
tags: {
'1234': {
tag: 'test',
},
'1235': {
tag: 'new',
},
},
},
},
since: Date.now() / 1000,
})
.post('/v3/get', {
...reqBody,
offset: 1,
})
.reply(200, {
list: {},
})
// mock cloud storage
const mockBucket = new MockBucket('test')
sinon.replace(
Storage.prototype,
'bucket',
sinon.fake.returns(mockBucket as never)
)
})
after(async () => {
sinon.restore()
await deleteIntegrations(user.id, [integration.id])
})
context('when integration is pocket', () => {
it('returns 200 with OK', async () => {
return request
.post(`${baseUrl}/import`)
.send({
integrationId: integration.id,
})
.set('Cookie', `auth=${authToken}`)
.expect(200)
})
})
})
})

View file

@ -6,6 +6,10 @@
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test"],
"include": [
"src",
"test",
"../integration-handler/test/integrations.test.ts"
],
"exclude": ["./src/generated", "./test"]
}

View file

@ -0,0 +1,5 @@
node_modules
build
.env*
Dockerfile
.dockerignore

View file

@ -0,0 +1,16 @@
# This file specifies files that are *not* uploaded to Google Cloud Platform
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
node_modules

View file

@ -0,0 +1,26 @@
FROM node:18.16-alpine
# Run everything after as non-privileged user.
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .eslintrc .
COPY /packages/integration-handler/package.json ./packages/integration-handler/package.json
RUN yarn install --pure-lockfile
COPY /packages/integration-handler ./packages/integration-handler
RUN yarn workspace @omnivore/integration-handler build
# After building, fetch the production dependencies
RUN rm -rf /app/packages/integration-handler/node_modules
RUN rm -rf /app/node_modules
RUN yarn install --pure-lockfile --production
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/integration-handler", "start_exporter"]

View file

@ -0,0 +1,26 @@
FROM node:18.16-alpine
# Run everything after as non-privileged user.
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .eslintrc .
COPY /packages/integration-handler/package.json ./packages/integration-handler/package.json
RUN yarn install --pure-lockfile
COPY /packages/integration-handler ./packages/integration-handler
RUN yarn workspace @omnivore/integration-handler build
# After building, fetch the production dependencies
RUN rm -rf /app/packages/integration-handler/node_modules
RUN rm -rf /app/node_modules
RUN yarn install --pure-lockfile --production
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/integration-handler", "start_importer"]

View file

@ -30,6 +30,7 @@
"@sentry/serverless": "^7.30.0",
"axios": "^1.2.2",
"csv-stringify": "^6.4.0",
"dotenv": "^16.0.1",
"jsonwebtoken": "^8.5.1",
"luxon": "^3.2.1",
"uuid": "^9.0.0"

View file

@ -1,11 +1,12 @@
import { File, Storage } from '@google-cloud/storage'
import * as Sentry from '@sentry/serverless'
import * as jwt from 'jsonwebtoken'
import { getIntegrationClient, updateIntegration } from './integrations'
import { search } from './item'
import { stringify } from 'csv-stringify'
import * as dotenv from 'dotenv' // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import
import * as jwt from 'jsonwebtoken'
import { DateTime } from 'luxon'
import { v4 as uuidv4 } from 'uuid'
import { File, Storage } from '@google-cloud/storage'
import { getIntegrationClient, updateIntegration } from './integrations'
import { search } from './item'
interface IntegrationRequest {
integrationId: string
@ -18,6 +19,8 @@ interface Claims {
token: string
}
dotenv.config()
Sentry.GCPFunction.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0,
@ -43,6 +46,8 @@ const createGCSFile = (bucket: string, filename: string): File => {
export const exporter = Sentry.GCPFunction.wrapHttpFunction(
async (req, res) => {
console.log('start to export to integration')
const JWT_SECRET = process.env.JWT_SECRET
const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT
@ -50,8 +55,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
return res.status(500).send('Environment not configured correctly')
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const token = (req.cookies?.token || req.headers.authorization) as string
const token = req.get('Omnivore-Authorization')
if (!token) {
return res.status(401).send({ errorCode: 'INVALID_TOKEN' })
}
@ -77,9 +81,10 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
let hasMore = true
let after = '0'
while (hasMore) {
console.log('searching for items...')
const response = await search(
REST_BACKEND_ENDPOINT,
claims.token,
token,
client.highlightOnly,
new Date(syncAt),
'50',
@ -100,6 +105,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
break
}
console.log('exporting items...')
const synced = await client.export(claims.token, items)
if (!synced) {
console.error('failed to export item', {
@ -108,6 +114,7 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
return res.status(400).send('Failed to sync')
}
console.log('updating integration...')
// update integration syncedAt if successful
const updated = await updateIntegration(
REST_BACKEND_ENDPOINT,
@ -115,7 +122,8 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
items[items.length - 1].updatedAt,
integrationName,
claims.token,
token
token,
'EXPORT'
)
if (!updated) {
@ -128,6 +136,8 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
// avoid rate limiting
await wait(500)
}
console.log('done')
} catch (err) {
console.error('export with integration failed', err)
return res.status(500).send(err)
@ -139,16 +149,17 @@ export const exporter = Sentry.GCPFunction.wrapHttpFunction(
export const importer = Sentry.GCPFunction.wrapHttpFunction(
async (req, res) => {
console.log('start to import from integration')
const JWT_SECRET = process.env.JWT_SECRET
const REST_BACKEND_ENDPOINT = process.env.REST_BACKEND_ENDPOINT
const GCS_BUCKET = process.env.GCS_BUCKET
const GCS_BUCKET = process.env.GCS_UPLOAD_BUCKET
if (!JWT_SECRET || !REST_BACKEND_ENDPOINT || !GCS_BUCKET) {
return res.status(500).send('Environment not configured correctly')
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const token = (req.cookies?.token || req.headers.authorization) as string
const token = req.get('Omnivore-Authorization')
if (!token) {
return res.status(401).send({ errorCode: 'INVALID_TOKEN' })
}
@ -175,6 +186,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction(
let syncedAt = req.body.syncAt
const since = syncedAt
console.log('importing pages from integration...')
// get pages from integration
const retrieved = await integrationClient.retrieve({
token: claims.token,
@ -183,6 +195,8 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction(
})
syncedAt = retrieved.since || Date.now()
console.log('uploading items...')
let retrievedData = retrieved.data
// if there are pages to import
if (retrievedData.length > 0) {
@ -224,6 +238,7 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction(
size: retrievedData.length,
})
console.log('uploading integration...')
// update the integration's syncedAt and remove taskName
const result = await updateIntegration(
REST_BACKEND_ENDPOINT,
@ -231,7 +246,8 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction(
new Date(syncedAt),
req.body.integrationName,
claims.token,
token
token,
'IMPORT'
)
if (!result) {
console.error('failed to update integration', {
@ -241,10 +257,13 @@ export const importer = Sentry.GCPFunction.wrapHttpFunction(
}
} while (retrievedData.length > 0 && offset < 20000) // limit to 20k pages
}
console.log('done')
} catch (err) {
console.error('import pages from integration failed', err)
return res.status(500).send(err)
} finally {
console.log('closing write stream')
writeStream?.end()
}

View file

@ -30,7 +30,8 @@ export const updateIntegration = async (
syncedAt: Date,
name: string,
integrationToken: string,
token: string
token: string,
type: string
): Promise<boolean> => {
const requestData = JSON.stringify({
query: `
@ -48,11 +49,14 @@ export const updateIntegration = async (
}
}`,
variables: {
id,
syncedAt,
name,
token: integrationToken,
enabled: true,
input: {
id,
syncedAt,
name,
token: integrationToken,
enabled: true,
type,
},
},
})

View file

@ -59,11 +59,8 @@ export const search = async (
node {
id
slug
labels {
id
}
isArchived
readingProgressPercent
url
updatedAt
title
image
author
@ -77,6 +74,10 @@ export const search = async (
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
... on SearchError {
errorCodes