新增构建OpenSSL镜像相关文件

This commit is contained in:
2024-03-15 14:52:38 +08:00
committed by huty
parent 43337c1a0b
commit 132c17af2d
10119 changed files with 1581963 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest"
},
"plugins": ["@typescript-eslint"],
"rules": {
"semi": [2, "never"]
}
}

View File

@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

View File

@@ -0,0 +1,59 @@
FROM node:18.19.0 as builder
WORKDIR /app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN apt-get update && apt-get install -y g++ make python3
COPY ./package.json .
COPY ./yarn.lock .
COPY ./tsconfig.json .
COPY ./.prettierrc .
COPY ./.eslintrc .
COPY ./readabilityjs/package.json ./packages/readabilityjs/package.json
COPY ./api/package.json ./packages/api/package.json
COPY ./text-to-speech/package.json ./packages/text-to-speech/package.json
COPY ./content-handler/package.json ./packages/content-handler/package.json
COPY ./liqe/package.json ./packages/liqe/package.json
RUN yarn config set registry https://registry.npm.taobao.org && yarn install --pure-lockfile
ADD ./readabilityjs ./packages/readabilityjs
ADD ./api ./packages/api
ADD ./text-to-speech ./packages/text-to-speech
ADD ./content-handler ./packages/content-handler
ADD ./liqe ./packages/liqe
RUN yarn workspace @omnivore/text-to-speech-handler build
RUN yarn workspace @omnivore/content-handler build
RUN yarn workspace @omnivore/liqe build
RUN yarn workspace @omnivore/api build
# After building, fetch the production dependencies
RUN rm -rf /app/packages/api/node_modules
RUN rm -rf /app/node_modules
RUN yarn install --pure-lockfile --production
FROM node:18.19.0 as runner
RUN apt-get update && apt-get install -y netcat-openbsd
WORKDIR /app
ENV NODE_ENV production
ENV NODE_OPTIONS=--max-old-space-size=4096
ENV PORT=8080
COPY --from=builder /app/packages/api/dist /app/packages/api/dist
COPY --from=builder /app/packages/readabilityjs/ /app/packages/readabilityjs/
COPY --from=builder /app/packages/api/package.json /app/packages/api/package.json
COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/packages/text-to-speech/ /app/packages/text-to-speech/
COPY --from=builder /app/packages/content-handler/ /app/packages/content-handler/
COPY --from=builder /app/packages/liqe/ /app/packages/liqe/
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/api", "start"]

View File

@@ -0,0 +1,8 @@
node_modules
dist
.DS_Store
.env*
Dockerfile
.dockerignore
*.yaml
.secrets*.yaml

View File

@@ -0,0 +1,30 @@
API_ENV=local
PG_HOST=localhost
PG_PORT=5432
PG_USER=app_user
PG_PASSWORD=app_pass
PG_POOL_MAX=20
PG_DB=omnivore
JWT_SECRET=some_secret
SSO_JWT_SECRET=some_sso_secret
CLIENT_URL=http://localhost:3000
GATEWAY_URL=http://localhost:4000/api
IMAGE_PROXY_URL=http://localhost:8080
IMAGE_PROXY_SECRET_KEY=some-secret
GAUTH_IOS_CLIENT_ID=
GAUTH_ANDROID_CLIENT_ID=
GAUTH_CLIENT_ID='notset'
GAUTH_SECRET='notset'
GCP_PROJECT_ID=omnivore-local
INTERCOM_TOKEN=
SENTRY_DSN=
JAEGER_HOST=
SAMPLE_METRICS_LOCALLY=FALSE
GCS_UPLOAD_BUCKET=
GCS_UPLOAD_SA_KEY_FILE_PATH=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
SENDER_MESSAGE=msgs@sender.domain
SENDER_FEEDBACK=feedback@sender.domain
SENDER_GENERAL=no-reply@sender.domain
CONTENT_FETCH_URL=http://localhost:9090/

View File

@@ -0,0 +1,30 @@
API_ENV=local
PG_HOST=localhost
PG_PORT=5432
PG_USER=app_user
PG_PASSWORD=app_pass
PG_POOL_MAX=20
PG_DB=omnivore
JWT_SECRET=some_secret
SSO_JWT_SECRET=some_sso_secret
CLIENT_URL=http://localhost:3000
GATEWAY_URL=http://localhost:4000/api
IMAGE_PROXY_URL=http://localhost:8080
IMAGE_PROXY_SECRET_KEY=some-secret
GAUTH_IOS_CLIENT_ID=
GAUTH_ANDROID_CLIENT_ID=
GAUTH_CLIENT_ID='notset'
GAUTH_SECRET='notset'
GCP_PROJECT_ID=omnivore-local
INTERCOM_TOKEN=
SENTRY_DSN=
JAEGER_HOST=
SAMPLE_METRICS_LOCALLY=FALSE
GCS_UPLOAD_BUCKET=
GCS_UPLOAD_SA_KEY_FILE_PATH=
GCS_UPLOAD_PRIVATE_BUCKET=
TWITTER_BEARER_TOKEN=
PREVIEW_IMAGE_WRAPPER_ID='selected_highlight_wrapper'
SEGMENT_WRITE_KEY='test'
PUBSUB_VERIFICATION_TOKEN='123456'
CONTENT_FETCH_URL=http://localhost:9090/

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
readabilityjs/
src/generated/
test/resolvers/

View File

@@ -0,0 +1,9 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unsafe-argument": 0
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "@istanbuljs/nyc-config-typescript",
"check-coverage": true,
"all": true,
"include": [
"src/**/*.ts"
],
"reporter": [
"text-summary"
],
"branches": 40,
"lines": 0,
"functions": 0,
"statements": 60
}

View File

@@ -0,0 +1,59 @@
FROM node:18.16 as builder
WORKDIR /app
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN apt-get update && apt-get install -y g++ make python3
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY .prettierrc .
COPY .eslintrc .
COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
COPY /packages/api/package.json ./packages/api/package.json
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
COPY /packages/liqe/package.json ./packages/liqe/package.json
RUN yarn install --pure-lockfile
ADD /packages/readabilityjs ./packages/readabilityjs
ADD /packages/api ./packages/api
ADD /packages/text-to-speech ./packages/text-to-speech
ADD /packages/content-handler ./packages/content-handler
ADD /packages/liqe ./packages/liqe
RUN yarn workspace @omnivore/text-to-speech-handler build
RUN yarn workspace @omnivore/content-handler build
RUN yarn workspace @omnivore/liqe build
RUN yarn workspace @omnivore/api build
# After building, fetch the production dependencies
RUN rm -rf /app/packages/api/node_modules
RUN rm -rf /app/node_modules
RUN yarn install --pure-lockfile --production
FROM node:18.16 as runner
RUN apt-get update && apt-get install -y netcat-openbsd
WORKDIR /app
ENV NODE_ENV production
ENV NODE_OPTIONS=--max-old-space-size=4096
ENV PORT=8080
COPY --from=builder /app/packages/api/dist /app/packages/api/dist
COPY --from=builder /app/packages/readabilityjs/ /app/packages/readabilityjs/
COPY --from=builder /app/packages/api/package.json /app/packages/api/package.json
COPY --from=builder /app/packages/api/node_modules /app/packages/api/node_modules
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/packages/text-to-speech/ /app/packages/text-to-speech/
COPY --from=builder /app/packages/content-handler/ /app/packages/content-handler/
COPY --from=builder /app/packages/liqe/ /app/packages/liqe/
EXPOSE 8080
CMD ["yarn", "workspace", "@omnivore/api", "start"]

View File

@@ -0,0 +1,30 @@
FROM node:18.16-alpine
WORKDIR /app
COPY package.json .
COPY yarn.lock .
COPY tsconfig.json .
COPY /packages/readabilityjs/package.json ./packages/readabilityjs/package.json
COPY /packages/api/package.json ./packages/api/package.json
COPY /packages/text-to-speech/package.json ./packages/text-to-speech/package.json
COPY /packages/content-handler/package.json ./packages/content-handler/package.json
COPY /packages/liqe/package.json ./packages/liqe/package.json
RUN apk --no-cache --virtual build-dependencies add \
python3 \
make \
g++
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true
RUN yarn install
COPY /packages/readabilityjs ./packages/readabilityjs
COPY /packages/api ./packages/api
COPY /packages/text-to-speech ./packages/text-to-speech
COPY /packages/content-handler ./packages/content-handler
COPY /packages/liqe ./packages/liqe
CMD ["yarn", "workspace", "@omnivore/api", "test"]

View File

@@ -0,0 +1,53 @@
# Backend
This workspace is dedicated to API server that uses Apollo GraphQL server and Knex query builder to provide the app with data operations.
## GraphQL schema
GraphQL schema is located in `schema.ts`. In order to use new types, queries or mutations you need to declare them there and then run `yarn gql-typegen` from the application root to create the necessary typings in order to write GQL queries from the app.
## Apollo resolvers and data sources
We make use of Apollo resolvers and data sources. Resolvers typically contain business logic and handling of user domain errors, while data sources are ideally simple atomic database operations.
## Interacting with the database
All operations on the database must be wrapped in Knex transaction on a resolver layer. This ensures data integrity and safety with no side effects on a failed operations.
Because we make use of Row Level Security in the database, - all operations typically begin with assuming the role for which policies exist via `omnivore.set_claims` database function.
## ElasticSearch
We use ElasticSearch to store page data in a distributed manner. This is a great way to store data that is not easily searchable.
All the page data is stored in a single index `pages`. This index is then queried by the app to display the data.
You need to make sure you have an elasticsearch instance running locally (or just use docker compose).
ES url is specified by `ES_URL` environment variable (username `ES_USERNAME` and password `ES_PASSWORD` can be random strings in local environment).
When you're running elastic for the very first time, you need to create indices and ingest existing data. This can be done by running `python elastic_migrate.py`.
This operation is idempotent, so you can always run `python elastic_migrate.py` again to re-ingest all the data.
You can run ElasticSearch separately by using `docker compose -f docker-compose.yml up -d elastic`.
## Image Proxy (optional for local dev)
Backend API server returns article image links using image proxy
(/imageproxy). You will need to set the env with var IMAGE_PROXY_URL to point
to a running instance of image proxy along with env var IMAGE_PROXY_SECRET. The
same secret env var ought to be passed as config to the running image proxy
service. You can also use the docker-compose-dev.yml file to bring up just the
image proxy service alone (w/ env var for secret specified in the compose file)
by running: `docker compose -f docker-compose-dev.yml up -d imageproxy`.
When running locally, use the .env.local file to set up the env variables in your environment.
### Set up the database
Refer the [using locally](../db/README.md#using-locally) section from db README.
### Copy .env.example file to .env file:
cp .env.example .env
### Run the app
yarn dev

View File

@@ -0,0 +1,5 @@
module.exports = {
service: {
localSchemaFile: './src/generated/schema.graphql',
},
}

View File

@@ -0,0 +1,6 @@
{
"extension": ["ts"],
"spec": "test/**/*.test.ts",
"reporter": "mocha-unfunk-reporter",
"require": ["test/babel-register.js", "test/global-setup.ts", "test/global-teardown.ts"]
}

View File

@@ -0,0 +1,154 @@
{
"name": "@omnivore/api",
"version": "1.0.0",
"license": "UNLICENSED",
"scripts": {
"build": "tsc && yarn copy-files",
"dev": "ts-node-dev --files src/server.ts",
"start": "node dist/server.js",
"lint": "eslint src --ext ts,js,tsx,jsx",
"lint:fix": "eslint src --fix --ext ts,js,tsx,jsx",
"test:typecheck": "tsc --noEmit",
"test": "nyc mocha -r ts-node/register --config mocha-config.json --timeout 10000",
"copy-files": "copyfiles -u 1 src/**/*.html dist/"
},
"dependencies": {
"@google-cloud/logging-winston": "^6.0.0",
"@google-cloud/monitoring": "^4.0.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.0.0",
"@google-cloud/pubsub": "^4.0.0",
"@google-cloud/storage": "^7.0.1",
"@google-cloud/tasks": "^4.0.0",
"@graphql-tools/utils": "^9.1.1",
"@omnivore/content-handler": "1.0.0",
"@omnivore/liqe": "1.0.0",
"@omnivore/readability": "1.0.0",
"@omnivore/text-to-speech-handler": "1.0.0",
"@opentelemetry/api": "^1.0.1",
"@opentelemetry/core": "^1.3.1",
"@opentelemetry/exporter-jaeger": "^1.0.1",
"@opentelemetry/instrumentation-dns": "^0.31.2",
"@opentelemetry/instrumentation-express": "^0.28.0",
"@opentelemetry/instrumentation-graphql": "^0.29.0",
"@opentelemetry/instrumentation-grpc": "^0.37.0",
"@opentelemetry/instrumentation-http": "^0.27.0",
"@opentelemetry/instrumentation-pg": "^0.35.1",
"@opentelemetry/node": "^0.24.0",
"@opentelemetry/resources": "^1.17.0",
"@opentelemetry/semantic-conventions": "^1.0.1",
"@opentelemetry/tracing": "^0.24.0",
"@sendgrid/mail": "^7.6.0",
"@sentry/integrations": "^7.10.0",
"@sentry/node": "^5.26.0",
"@sentry/tracing": "^7.9.0",
"addressparser": "^1.0.1",
"analytics-node": "^6.0.0",
"apollo-datasource": "^3.3.1",
"apollo-server-express": "^3.6.3",
"axios": "^0.27.2",
"bcryptjs": "^2.4.3",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"csv-stringify": "^6.4.0",
"dataloader": "^2.0.0",
"diff-match-patch": "^1.0.5",
"dompurify": "^2.0.17",
"dot-case": "^3.0.4",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-http-context2": "^1.0.0",
"express-rate-limit": "^6.3.0",
"fast-safe-stringify": "^2.1.1",
"firebase-admin": "^11.5.0",
"googleapis": "^125.0.0",
"graphql": "^15.3.0",
"graphql-fields": "^2.0.3",
"graphql-middleware": "^6.0.10",
"graphql-shield": "^7.5.0",
"highlightjs": "^9.16.2",
"html-entities": "^2.3.2",
"intercom-client": "^3.1.4",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "^2.0.3",
"linkedom": "^0.14.9",
"lodash": "^4.17.21",
"luxon": "^3.2.1",
"nanoid": "^3.1.25",
"node-html-markdown": "^1.3.0",
"nodemailer": "^6.7.3",
"normalize-url": "^6.1.0",
"oauth": "^0.10.0",
"pg": "^8.3.3",
"postgrator": "^4.2.0",
"private-ip": "^2.3.3",
"rss-parser": "^3.13.0",
"sanitize-html": "^2.3.2",
"sax": "^1.3.0",
"search-query-parser": "^1.6.0",
"snake-case": "^3.0.3",
"supertest": "^6.2.2",
"ts-loader": "^9.3.0",
"typeorm": "^0.3.4",
"typeorm-naming-strategies": "^4.1.0",
"underscore": "^1.13.6",
"urlsafe-base64": "^1.0.0",
"uuid": "^8.3.1",
"voca": "^1.4.0",
"winston": "^3.3.3",
"word-counting": "^1.1.4"
},
"devDependencies": {
"@babel/register": "^7.14.5",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@types/addressparser": "^1.0.1",
"@types/analytics-node": "^3.1.7",
"@types/bcryptjs": "^2.4.2",
"@types/chai": "^4.2.18",
"@types/chai-as-promised": "^7.1.5",
"@types/chai-string": "^1.4.2",
"@types/cookie": "^0.4.0",
"@types/cookie-parser": "^1.4.2",
"@types/csv-stringify": "^3.1.0",
"@types/diff-match-patch": "^1.0.32",
"@types/dompurify": "^2.0.4",
"@types/express": "^4.17.7",
"@types/graphql-fields": "^1.3.4",
"@types/highlightjs": "^9.12.2",
"@types/intercom-client": "^2.11.8",
"@types/jsonwebtoken": "^8.5.0",
"@types/luxon": "^1.25.0",
"@types/mocha": "^8.2.2",
"@types/nanoid": "^3.0.0",
"@types/nodemailer": "^6.4.4",
"@types/oauth": "^0.9.1",
"@types/private-ip": "^1.0.0",
"@types/sanitize-html": "^1.27.1",
"@types/sax": "^1.2.7",
"@types/sinon": "^10.0.13",
"@types/sinon-chai": "^3.2.8",
"@types/supertest": "^2.0.11",
"@types/urlsafe-base64": "^1.0.28",
"@types/uuid": "^8.3.0",
"@types/voca": "^1.4.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"chai-string": "^1.5.0",
"circular-dependency-plugin": "^5.2.0",
"copyfiles": "^2.4.1",
"mocha": "^9.0.1",
"mocha-unfunk-reporter": "^0.4.0",
"nock": "^13.2.4",
"nyc": "^15.1.0",
"postgrator": "^4.2.0",
"sinon": "^14.0.0",
"sinon-chai": "^3.7.0",
"ts-node-dev": "^1.1.8"
},
"engines": {
"node": "18.16.1"
},
"volta": {
"extends": "../../package.json"
}
}

View File

@@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
import { makeExecutableSchema } from '@graphql-tools/schema'
import * as Sentry from '@sentry/node'
import { ContextFunction } from 'apollo-server-core'
import { ApolloServer } from 'apollo-server-express'
import { ExpressContext } from 'apollo-server-express/dist/ApolloServer'
import * as httpContext from 'express-http-context2'
import * as jwt from 'jsonwebtoken'
import { EntityManager } from 'typeorm'
import { promisify } from 'util'
import { appDataSource } from './data_source'
import { sanitizeDirectiveTransformer } from './directives'
import { env } from './env'
import { createPubSubClient } from './pubsub'
import { functionResolvers } from './resolvers/function_resolvers'
import { ClaimsToSet, ResolverContext } from './resolvers/types'
import ScalarResolvers from './scalars'
import typeDefs from './schema'
import { tracer } from './tracing'
import { getClaimsByToken, setAuthInCookie } from './utils/auth'
import { SetClaimsRole } from './utils/dictionary'
import { logger } from './utils/logger'
const signToken = promisify(jwt.sign)
const pubsub = createPubSubClient()
const resolvers = {
...functionResolvers,
...ScalarResolvers,
}
const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
req,
res,
}) => {
logger.info(`handling gql request`, {
query: req.body.query,
variables: req.body.variables,
})
const token = req?.cookies?.auth || req?.headers?.authorization
const claims = await getClaimsByToken(token)
httpContext.set('claims', claims)
async function setClaims(
em: EntityManager,
uuid?: string,
userRole?: string
): Promise<void> {
const uid =
(claims && claims.uid) || uuid || '00000000-0000-0000-0000-000000000000'
const dbRole =
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
return em.query('SELECT * from omnivore.set_claims($1, $2)', [uid, dbRole])
}
const ctx = {
log: logger,
claims,
pubsub,
// no caching for subscriptions
clearAuth: () => {
res.clearCookie('auth')
res.clearCookie('pendingUserAuth')
},
signToken,
setAuth: async (
claims: ClaimsToSet,
secret: string = env.server.jwtSecret
) => await setAuthInCookie(claims, res, secret),
setClaims,
authTrx: <TResult>(
cb: (em: EntityManager) => TResult,
userRole?: string
): Promise<TResult> =>
appDataSource.transaction(async (tx) => {
await setClaims(tx, undefined, userRole)
return cb(tx)
}),
tracingSpan: tracer.startSpan('apollo.request'),
}
return ctx
}
export function makeApolloServer(): ApolloServer {
let schema = makeExecutableSchema({
resolvers,
typeDefs,
})
schema = sanitizeDirectiveTransformer(schema)
const apollo = new ApolloServer({
schema: schema,
context: contextFunc,
formatError: (err) => {
logger.info('server error', err)
Sentry.captureException(err)
// hide error messages from frontend on prod
return new Error('Unexpected server error')
},
introspection: env.dev.isLocal,
persistedQueries: false,
})
return apollo
}

View File

@@ -0,0 +1,21 @@
import { DataSource } from 'typeorm'
import { SnakeNamingStrategy } from 'typeorm-naming-strategies'
import { env } from './env'
import { CustomTypeOrmLogger } from './utils/logger'
export const appDataSource = new DataSource({
type: 'postgres',
host: env.pg.host,
port: env.pg.port,
schema: 'omnivore',
username: env.pg.userName,
password: env.pg.password,
database: env.pg.dbName,
logging: ['query', 'info'],
entities: [__dirname + '/entity/**/*{.js,.ts}'],
subscribers: [__dirname + '/events/**/*{.js,.ts}'],
namingStrategy: new SnakeNamingStrategy(),
logger: new CustomTypeOrmLogger(['query', 'info']),
connectTimeoutMS: 40000, // 40 seconds
maxQueryExecutionTime: 10000, // 10 seconds
})

View File

@@ -0,0 +1,50 @@
import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils'
import { GraphQLNonNull, GraphQLScalarType, GraphQLSchema } from 'graphql'
import { SanitizedString } from './scalars'
export const sanitizeDirectiveTransformer = (schema: GraphQLSchema) => {
return mapSchema(schema, {
[MapperKind.FIELD]: (fieldConfig) => {
const sanitizeDirective = getDirective(
schema,
fieldConfig,
'sanitize'
)?.[0]
if (!sanitizeDirective) {
return fieldConfig
}
const maxLength = sanitizeDirective.maxLength as number | undefined
const minLength = sanitizeDirective.minLength as number | undefined
const allowedTags = sanitizeDirective.allowedTags as string[] | undefined
const pattern = sanitizeDirective.pattern as string | undefined
if (
fieldConfig.type instanceof GraphQLNonNull &&
fieldConfig.type.ofType instanceof GraphQLScalarType
) {
fieldConfig.type = new GraphQLNonNull(
new SanitizedString(
fieldConfig.type.ofType,
allowedTags,
maxLength,
minLength,
pattern
)
)
} else if (fieldConfig.type instanceof GraphQLScalarType) {
fieldConfig.type = new SanitizedString(
fieldConfig.type,
allowedTags,
maxLength,
minLength,
pattern
)
} else {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Not a scalar type: ${fieldConfig.type}`)
}
return fieldConfig
},
})
}

View File

@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm'
import { User } from './user'
@Entity()
@Unique('user_id_name', ['user', 'name'])
export class ApiKey {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
name!: string
@Column('text')
key!: string
@Column('text', { array: true })
scopes?: string[]
@CreateDateColumn()
createdAt!: Date
@Column('timestamp')
expiresAt!: Date
@Column('timestamp', { nullable: true })
usedAt!: Date | null
}

View File

@@ -0,0 +1,40 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Label } from './label'
// for labels created by rules, we use the rule name as the source, for example: 'rule:my-rule'
// for labels created by users, we use 'user'
// for labels created by system, we use 'system'
type RuleSourceType = `rule:${string}`
export type LabelSource = 'user' | 'system' | RuleSourceType
export const isLabelSource = (source: string): source is LabelSource => {
return ['user', 'system'].indexOf(source) !== -1 || source.startsWith('rule:')
}
@Entity({ name: 'entity_labels' })
export class EntityLabel {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('uuid')
labelId!: string
@ManyToOne(() => Label)
@JoinColumn({ name: 'label_id' })
label!: Label
@Column('uuid')
libraryItemId?: string | null
@Column('uuid')
highlightId?: string | null
@Column('text', { default: 'user' })
source!: LabelSource
}

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'features' })
@Unique(['user', 'name'])
export class Feature {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
name!: string
@Column('timestamp', { nullable: true })
grantedAt?: Date | null
@Column('timestamp', { nullable: true })
expiresAt?: Date | null
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
}

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
@Entity()
export class Feed {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
title!: string
@Column('text')
url!: string
@Column('text')
author?: string
@Column('text')
description?: string
@Column('text')
image?: string
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('timestamptz')
publishedAt?: Date | null
}

View File

@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'filters' })
@Unique('filter_unique_key', ['user', 'name'])
export class Filter {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('varchar', { length: 255 })
name!: string
@Column('varchar', { length: 255, nullable: true, default: null })
description?: string | null
@Column('varchar', { length: 255 })
filter!: string
@Column('varchar', { length: 255, default: 'Search' })
category!: string
@Column('integer', { default: 0 })
position!: number
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('boolean', { default: false })
defaultFilter!: boolean
@Column('boolean', { default: true })
visible!: boolean
@Column('text')
folder!: string
}

View File

@@ -0,0 +1,26 @@
import {
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_friends' })
export class Follower {
@PrimaryGeneratedColumn('uuid')
id?: string
@OneToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@OneToOne(() => User)
@JoinColumn({ name: 'friend_user_id' })
followee!: User
@CreateDateColumn()
createdAt?: Date
}

View File

@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
import { GroupMembership } from './group_membership'
@Entity()
export class Group {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
name!: string
@OneToOne(() => User)
@JoinColumn()
createdBy!: User
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(() => GroupMembership, (groupMembership) => groupMembership.group)
members!: GroupMembership[]
@Column('text', { nullable: true })
description?: string | null
@Column('text', { nullable: true })
topics?: string | null
@Column('boolean', { default: false })
onlyAdminCanPost!: boolean
@Column('boolean', { default: false })
onlyAdminCanSeeMembers!: boolean
}

View File

@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
import { Group } from './group'
import { Invite } from './invite'
@Entity()
@Unique('group_membership_unique', ['user', 'group'])
export class GroupMembership {
@PrimaryGeneratedColumn('uuid')
id!: string
@OneToOne(() => User)
@JoinColumn()
user!: User
@ManyToOne(() => Group, (group) => group.members)
@JoinColumn()
group!: Group
@OneToOne(() => Invite)
@JoinColumn()
invite!: Invite
@CreateDateColumn()
createdAt?: Date
@UpdateDateColumn()
updatedAt?: Date
@Column('boolean', { default: false })
isAdmin!: boolean
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
import { Group } from './group'
@Entity()
export class Invite {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
code!: string
@OneToOne(() => User)
@JoinColumn()
createdBy!: User
@OneToOne(() => Group)
@JoinColumn()
group!: Group
@Column('integer')
maxMembers!: number
@Column('timestamp')
expirationTime!: Date
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
}

View File

@@ -0,0 +1,87 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { Label } from './label'
import { LibraryItem } from './library_item'
import { User } from './user'
export enum HighlightType {
Highlight = 'HIGHLIGHT',
Redaction = 'REDACTION', // allowing people to remove text from the page
Note = 'NOTE', // to be deleted in favor of note on library item
}
@Entity({ name: 'highlight' })
export class Highlight {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column({ type: 'varchar', length: 14 })
shortId!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'library_item_id' })
libraryItem!: LibraryItem
@Column('text')
quote?: string | null
@Column({ type: 'varchar', length: 5000 })
prefix?: string | null
@Column({ type: 'varchar', length: 5000 })
suffix?: string | null
@Column('text')
patch?: string | null
@Column('text')
annotation?: string | null
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt?: Date | null
@Column('timestamp')
sharedAt?: Date
@Column('real', { default: 0 })
highlightPositionPercent!: number
@Column('integer', { default: 0 })
highlightPositionAnchorIndex!: number
@Column('enum', {
enum: HighlightType,
default: HighlightType.Highlight,
})
highlightType!: HighlightType
@Column('text', { nullable: true })
html?: string | null
@Column('text', { nullable: true })
color?: string | null
@ManyToMany(() => Label, { cascade: true, eager: true })
@JoinTable({
name: 'entity_labels',
joinColumn: { name: 'highlight_id' },
inverseJoinColumn: { name: 'label_id' },
})
labels?: Label[]
}

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
export enum IntegrationType {
Export = 'EXPORT',
Import = 'IMPORT',
}
export enum ImportItemState {
Unread = 'UNREAD',
Unarchived = 'UNARCHIVED',
Archived = 'ARCHIVED',
All = 'ALL',
}
@Entity({ name: 'integrations' })
export class Integration {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('varchar', { length: 40 })
name!: string
@Column('enum', {
enum: IntegrationType,
default: IntegrationType.Export,
})
type!: IntegrationType
@Column('varchar', { length: 255 })
token!: string
@Column('boolean', { default: true })
enabled!: boolean
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('timestamp', { nullable: true })
syncedAt?: Date | null
@Column('text', { nullable: true })
taskName?: string | null
@Column('enum', { enum: ImportItemState, nullable: true })
importItemState?: ImportItemState | null
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'labels' })
export class Label {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
name!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
color!: string
@Column('text', { nullable: true })
description?: string | null
@CreateDateColumn()
createdAt!: Date
@Column('integer', { default: 0 })
position!: number
@Column('boolean', { default: false })
internal!: boolean
@UpdateDateColumn()
updatedAt?: Date | null
}

View File

@@ -0,0 +1,211 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm'
import { Highlight } from './highlight'
import { Label } from './label'
import { Recommendation } from './recommendation'
import { UploadFile } from './upload_file'
import { User } from './user'
export enum LibraryItemState {
Failed = 'FAILED',
Processing = 'PROCESSING',
Succeeded = 'SUCCEEDED',
Deleted = 'DELETED',
Archived = 'ARCHIVED',
}
export enum ContentReaderType {
WEB = 'WEB',
PDF = 'PDF',
EPUB = 'EPUB',
}
export enum DirectionalityType {
LTR = 'LTR',
RTL = 'RTL',
}
@Unique('library_item_user_original_url', ['user', 'originalUrl'])
@Entity({ name: 'library_item' })
export class LibraryItem {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('enum', {
enum: LibraryItemState,
default: LibraryItemState.Succeeded,
})
state!: LibraryItemState
@Column('text')
originalUrl!: string
@Column('text', { nullable: true })
downloadUrl?: string | null
@Column('text')
slug!: string
@Column('text')
title!: string
@Column('text', { nullable: true })
author?: string | null
@Column('text', { nullable: true })
description?: string | null
@Column('timestamptz')
savedAt!: Date
@CreateDateColumn()
createdAt!: Date
@Column('timestamptz', { nullable: true })
publishedAt?: Date | null
@Column('timestamptz')
archivedAt?: Date | null
@Column('timestamptz')
deletedAt?: Date | null
@Column('timestamptz')
readAt?: Date | null
@UpdateDateColumn()
updatedAt!: Date
@Column('text', { nullable: true })
itemLanguage?: string | null
@Column('integer', { nullable: true })
wordCount?: number | null
@Column('text', { nullable: true })
siteName?: string | null
@Column('text', { nullable: true })
siteIcon?: string | null
@Column('integer')
readingProgressLastReadAnchor!: number
@Column('integer')
readingProgressHighestReadAnchor!: number
@Column('real')
readingProgressTopPercent!: number
@Column('real')
readingProgressBottomPercent!: number
@Column('text', { nullable: true })
thumbnail?: string | null
@Column('text')
itemType!: string
@OneToOne(() => UploadFile, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'upload_file_id' })
uploadFile?: UploadFile
// get upload_file_id without joining relations
@Column('text', { nullable: true })
uploadFileId?: string
@Column('enum', { enum: ContentReaderType, default: ContentReaderType.WEB })
contentReader!: ContentReaderType
@Column('text', { nullable: true })
originalContent?: string | null
@Column('text')
readableContent!: string
// NOT SUPPORTED IN TYPEORM
// @Column('vector', { nullable: true })
// embedding?: number[]
@Column('text', { nullable: true })
textContentHash?: string | null
@Column('text', { nullable: true })
subscription?: string | null
@ManyToMany(() => Label, { cascade: true })
@JoinTable({
name: 'entity_labels',
joinColumn: { name: 'library_item_id' },
inverseJoinColumn: { name: 'label_id' },
})
labels?: Label[]
@OneToMany(
() => Recommendation,
(recommendation) => recommendation.libraryItem,
{ cascade: true }
)
@JoinTable({
name: 'recommendation',
joinColumn: { name: 'library_item_id' },
inverseJoinColumn: { name: 'id' },
})
recommendations?: Recommendation[]
@Column('enum', { enum: DirectionalityType, default: DirectionalityType.LTR })
directionality!: DirectionalityType
@OneToMany(() => Highlight, (highlight) => highlight.libraryItem, {
cascade: true,
})
@JoinTable({
name: 'highlight',
joinColumn: { name: 'library_item_id' },
inverseJoinColumn: { name: 'id' },
})
highlights?: Highlight[]
@Column('text', { nullable: true })
labelNames?: string[] | null
@Column('text', { nullable: true })
highlightLabels?: string[] | null
@Column('text', { nullable: true })
highlightAnnotations?: string[] | null
@Column('text', { nullable: true })
note?: string | null
@Column('text', { nullable: true })
recommenderNames?: string[] | null
@Column('jsonb')
links?: any | null
@Column('text')
previewContent?: string | null
@Column('text')
previewContentType?: string | null
@Column('text')
folder!: string
}

View File

@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
import { Subscription } from './subscription'
@Entity({ name: 'newsletter_emails' })
export class NewsletterEmail {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('varchar')
address!: string
@ManyToOne(() => User, (user) => user.newsletterEmails)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('varchar', { nullable: true })
confirmationCode?: string | null
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(() => Subscription, (subscription) => subscription.newsletterEmail)
subscriptions!: Subscription[]
}

View File

@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_profile' })
export class Profile {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
username!: string
@Column('text', { nullable: true })
bio?: string | null
@Column('text', { nullable: true })
pictureUrl?: string | null
@OneToOne(() => User, (user) => user.profile)
@JoinColumn({ name: 'user_id' })
user!: User
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@Column('boolean', { default: false })
private!: boolean
}

View File

@@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'received_emails' })
export class ReceivedEmail {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
from!: string
@Column('text')
to!: string
@Column('text')
subject!: string
@Column('text')
text!: string
@Column('text')
html!: string
@Column('text')
type!: 'article' | 'non-article'
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
}

View File

@@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Group } from './groups/group'
import { LibraryItem } from './library_item'
import { User } from './user'
@Entity()
export class Recommendation {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'recommender_id' })
recommender!: User
@ManyToOne(() => LibraryItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'library_item_id' })
libraryItem!: LibraryItem
@ManyToOne(() => Group, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'group_id' })
group!: Group
@Column('text', { nullable: true })
note?: string | null
@CreateDateColumn()
createdAt!: Date
}

View File

@@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'reminders' })
export class Reminder {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('uuid', { name: 'article_saving_request_id' })
articleSavingRequest?: string
@Column('uuid', { name: 'link_id' })
link?: string
@Column('boolean')
archiveUntil?: boolean
@Column('boolean')
sendNotification?: boolean
@Column('text')
taskName?: string
@Column('text')
status?: string
@Column('timestamp')
remindAt!: Date
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt?: Date
@Column('text')
elasticPageId?: string
}

View File

@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { ReportType } from '../../generated/graphql'
@Entity()
export class AbuseReport {
@PrimaryGeneratedColumn('uuid')
id?: string
@Column('text')
libraryItemId?: string
@Column('text')
sharedBy!: string
@Column('text')
itemUrl!: string
@Column('text')
reportedBy!: string
@Column('enum', { enum: ReportType, array: true })
reportTypes!: ReportType[]
@Column('text')
reportComment!: string
@CreateDateColumn()
createdAt?: Date
@UpdateDateColumn()
updatedAt?: Date
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from '../user'
@Entity()
export class ContentDisplayReport {
@PrimaryGeneratedColumn('uuid')
id?: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
libraryItemId?: string
@Column('text')
content!: string
@Column('text')
originalHtml!: string | undefined
@Column('text')
originalUrl!: string
@Column('text')
reportComment!: string
@CreateDateColumn()
createdAt?: Date
@UpdateDateColumn()
updatedAt?: Date
}

View File

@@ -0,0 +1,61 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
export enum RuleActionType {
AddLabel = 'ADD_LABEL',
Archive = 'ARCHIVE',
MarkAsRead = 'MARK_AS_READ',
SendNotification = 'SEND_NOTIFICATION',
}
export enum RuleEventType {
PageCreated = 'PAGE_CREATED',
PageUpdated = 'PAGE_UPDATED',
}
export interface RuleAction {
type: RuleActionType
params: string[]
}
@Entity({ name: 'rules' })
export class Rule {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
name!: string
@Column('text')
filter!: string
@Column('simple-json')
actions!: RuleAction[]
@Column('text', { nullable: true })
description?: string | null
@Column('text', { array: true })
eventTypes!: RuleEventType[]
@Column('boolean', { default: true })
enabled!: boolean
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
}

View File

@@ -0,0 +1,27 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'search_history' })
@Unique('search_history_user_id_term_key', ['user', 'term'])
export class SearchHistory {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('varchar', { length: 255 })
term!: string
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
}

View File

@@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
export enum SpeechState {
INITIALIZED = 'INITIALIZED',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'speech' })
export class Speech {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
elasticPageId!: string
@Column('text', { default: '' })
audioFileName!: string
@Column('text', { default: '' })
speechMarksFileName!: string
@Column('text')
voice!: string
@Column('enum', { enum: SpeechState, default: SpeechState.INITIALIZED })
state!: SpeechState
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
}

View File

@@ -0,0 +1,79 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { SubscriptionStatus, SubscriptionType } from '../generated/graphql'
import { NewsletterEmail } from './newsletter_email'
import { User } from './user'
@Entity({ name: 'subscriptions' })
export class Subscription {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
name!: string
@Column('enum', {
enum: SubscriptionStatus,
default: SubscriptionStatus.Active,
})
status!: SubscriptionStatus
@ManyToOne(() => NewsletterEmail, { nullable: true })
@JoinColumn({ name: 'newsletter_email_id' })
newsletterEmail?: NewsletterEmail | null
@Column('text', { nullable: true })
description?: string
@Column('text', { nullable: true })
url?: string
@Column('text', { nullable: true })
unsubscribeMailTo?: string
@Column('text', { nullable: true })
unsubscribeHttpUrl?: string
@Column('text', { nullable: true })
icon?: string | null
@Column('enum', {
enum: SubscriptionType,
})
type!: SubscriptionType
@Column('integer', { default: 0 })
count!: number
@Column('timestamp', { nullable: true })
lastFetchedAt?: Date | null
@Column('text', { nullable: true })
lastFetchedChecksum?: string | null
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('timestamp', { nullable: true })
scheduledAt?: Date | null
@Column('boolean')
isPrivate?: boolean | null
@Column('boolean')
autoAddToLibrary?: boolean | null
}

View File

@@ -0,0 +1,38 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'upload_files' })
export class UploadFile {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
url!: string
@Column('text')
fileName!: string
@Column('text')
contentType!: string
@Column('text')
status!: string
@CreateDateColumn()
createdAt?: Date
@UpdateDateColumn()
updatedAt?: Date
}

View File

@@ -0,0 +1,77 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { Label } from './label'
import { NewsletterEmail } from './newsletter_email'
import { Profile } from './profile'
import { Subscription } from './subscription'
import { UserPersonalization } from './user_personalization'
export enum RegistrationType {
Google = 'GOOGLE',
Apple = 'APPLE',
Email = 'EMAIL',
}
export enum StatusType {
Active = 'ACTIVE',
Pending = 'PENDING',
Deleted = 'DELETED',
}
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
name!: string
@Column({ type: 'enum', enum: RegistrationType })
source!: string
@Column('text')
email!: string
@Column('text')
sourceUserId!: string
@CreateDateColumn()
createdAt!: Date
@UpdateDateColumn()
updatedAt!: Date
@OneToMany(() => NewsletterEmail, (newsletterEmail) => newsletterEmail.user)
newsletterEmails?: NewsletterEmail[]
@OneToOne(() => Profile, (profile) => profile.user, {
eager: true,
cascade: true,
})
profile!: Profile
@Column('varchar', { length: 255, nullable: true })
password?: string
@OneToMany(() => Label, (label) => label.user)
labels?: Label[]
@OneToMany(() => Subscription, (subscription) => subscription.user)
subscriptions?: Subscription[]
@Column({ type: 'enum', enum: StatusType })
status!: StatusType
@OneToOne(
() => UserPersonalization,
(userPersonalization) => userPersonalization.user
)
userPersonalization!: UserPersonalization
}

View File

@@ -0,0 +1,25 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_device_tokens' })
export class UserDeviceToken {
@PrimaryGeneratedColumn('uuid')
id!: string
@Column('text')
token!: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user!: User
@CreateDateColumn()
createdAt!: Date
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'user_personalization' })
export class UserPersonalization {
@PrimaryGeneratedColumn('uuid')
id!: string
@OneToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text', { nullable: true })
fontFamily?: string | null
@Column('integer', { nullable: true })
fontSize?: number | null
@Column('text', { nullable: true })
margin?: number | null
@Column('text', { nullable: true })
theme?: string | null
@Column('text', { nullable: true })
libraryLayoutType?: string | null
@Column('text', { nullable: true })
librarySortOrder?: string | null
@Column('text', { nullable: true })
speechVoice?: string | null
@Column('text', { nullable: true })
speechSecondaryVoice?: string | null
@Column('text', { nullable: true })
speechRate?: string | null
@Column('text', { nullable: true })
speechVolume?: string | null
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
@Column('json')
fields?: any | null
}

View File

@@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm'
import { User } from './user'
@Entity({ name: 'webhooks' })
export class Webhook {
@PrimaryGeneratedColumn('uuid')
id!: string
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user!: User
@Column('text')
url!: string
@Column('text', { array: true })
eventTypes!: string[]
@Column('text', { default: 'POST' })
method!: string
@Column('text', { default: 'application/json' })
contentType!: string
@Column('boolean', { default: true })
enabled!: boolean
@CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date
@UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date
}

View File

@@ -0,0 +1,7 @@
import { getEnv } from './util'
export const env = getEnv()
export function homePageURL(): string {
return env.client.url
}

View File

@@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm'
import { ContentDisplayReport } from '../../entity/reports/content_display_report'
import { env } from '../../env'
import { logger } from '../../utils/logger'
import { sendEmail } from '../../utils/sendEmail'
@EventSubscriber()
export class ContentDisplayReportSubscriber
implements EntitySubscriberInterface<ContentDisplayReport>
{
listenTo() {
return ContentDisplayReport
}
async afterInsert(event: InsertEvent<ContentDisplayReport>): Promise<void> {
const report = event.entity
const message = `A new content display report was created by:
${report.user.id} for URL: ${report.originalUrl}
${report.reportComment}`
logger.info(message)
if (!env.dev.isLocal) {
// If we are in the local environment, just log a message, otherwise email the report
await sendEmail({
to: env.sender.feedback,
subject: 'New content display report',
text: message,
from: env.sender.message,
})
}
}
}

View File

@@ -0,0 +1,35 @@
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
} from 'typeorm'
import { GroupMembership } from '../../entity/groups/group_membership'
@EventSubscriber()
export class FollowAllGroupMembers
implements EntitySubscriberInterface<GroupMembership>
{
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
listenTo() {
return GroupMembership
}
async afterInsert(event: InsertEvent<GroupMembership>): Promise<void> {
// Make all existing group members follow the new user
await event.manager.query(
`insert into omnivore.user_friends (user_id, friend_user_id)
select user_id, $1 from omnivore.group_membership where group_id = $2 and user_id != $1
`,
[event.entity.user.id, event.entity.group.id]
)
// Make the new user follow all existing group members
await event.manager.query(
`insert into omnivore.user_friends (user_id, friend_user_id)
select $1, user_id from omnivore.group_membership where group_id = $2 and user_id != $1
`,
[event.entity.user.id, event.entity.group.id]
)
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

53
examples/omnivore/api/api/src/graphql.d.ts vendored Executable file
View File

@@ -0,0 +1,53 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/ban-types */
declare module '*.graphql' {
import { DocumentNode } from 'graphql'
const schema: DocumentNode
export = schema
}
declare module 'knex-stringcase' {
import { Knex } from 'knex'
type StringCase =
| 'camelcase'
| 'capitalcase'
| 'constcase'
| 'cramcase'
| 'decapitalcase'
| 'dotcase'
| 'enumcase'
| 'lowercase'
| 'pascalcase'
| 'pathcase'
| 'sentencecase'
| 'snakecase'
| 'spacecase'
| 'spinalcase'
| 'titlecase'
| 'trimcase'
| 'uppercase'
interface KnexStringCaseConfig extends Knex.Config {
appStringcase?: StringCase | StringCase[]
dbStringcase?: StringCase | StringCase[]
/* eslint-disable @typescript-eslint/no-explicit-any */
beforePostProcessResponse?(
result: any[] | object,
queryContext: object
): any[] | object
beforeWrapIdentifier?(value: string, queryContext: object): string
/* eslint-enable @typescript-eslint/no-explicit-any */
ignoreStringcase?(obj: object): boolean
}
function knexStringcase(config: KnexStringCaseConfig): Knex.Config
export = knexStringcase
}
declare module 'voca/slugify' {
function slugify(subject?: string): string
export = slugify
}

View File

@@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { rule, shield } from 'graphql-shield'
const isNotAuthenticated = rule({ cache: 'contextual' })(
async (_parent, _args, ctx, _info) => {
return ctx.claims?.uid === undefined
}
)
const permissions = shield({
Query: {
// me: isAuthenticated,
// article: and(isAuthenticated, isFullUser),
// articles: and(isAuthenticated, isFullUser),
},
Mutation: {
googleSignup: isNotAuthenticated,
},
})
export default permissions

View File

@@ -0,0 +1,159 @@
import { PubSub } from '@google-cloud/pubsub'
import express from 'express'
import { env } from './env'
import { ReportType } from './generated/graphql'
import { deepDelete } from './utils/helpers'
import { buildLogger } from './utils/logger'
const logger = buildLogger('pubsub')
const client = new PubSub()
export const createPubSubClient = (): PubsubClient => {
const fieldsToDelete = ['user'] as const
const publish = (topicName: string, msg: Buffer): Promise<void> => {
if (env.dev.isLocal) {
logger.info(`Publishing ${topicName}: ${msg.toString()}`)
return Promise.resolve()
}
return client
.topic(topicName)
.publishMessage({ data: msg })
.catch((err) => {
logger.error(`[PubSub] error: ${topicName}`, err)
})
.then(() => {
return Promise.resolve()
})
}
return {
userCreated: (
userId: string,
email: string,
name: string,
username: string
): Promise<void> => {
return publish(
'userCreated',
Buffer.from(JSON.stringify({ userId, email, name, username }))
)
},
entityCreated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
const cleanData = deepDelete(
data as T & Record<typeof fieldsToDelete[number], unknown>,
[...fieldsToDelete]
)
return publish(
'entityCreated',
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
)
},
entityUpdated: <T>(
type: EntityType,
data: T,
userId: string
): Promise<void> => {
const cleanData = deepDelete(
data as T & Record<typeof fieldsToDelete[number], unknown>,
[...fieldsToDelete]
)
return publish(
'entityUpdated',
Buffer.from(JSON.stringify({ type, userId, ...cleanData }))
)
},
entityDeleted: (
type: EntityType,
id: string,
userId: string
): Promise<void> => {
return publish(
'entityDeleted',
Buffer.from(JSON.stringify({ type, id, userId }))
)
},
reportSubmitted: (
submitterId: string,
itemUrl: string,
reportType: ReportType[],
reportComment: string
): Promise<void> => {
return publish(
'reportSubmitted',
Buffer.from(
JSON.stringify({ submitterId, itemUrl, reportType, reportComment })
)
)
},
}
}
export enum EntityType {
PAGE = 'page',
HIGHLIGHT = 'highlight',
LABEL = 'label',
}
export interface PubsubClient {
userCreated: (
userId: string,
email: string,
name: string,
username: string
) => Promise<void>
entityCreated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityUpdated: <T>(type: EntityType, data: T, userId: string) => Promise<void>
entityDeleted: (type: EntityType, id: string, userId: string) => Promise<void>
reportSubmitted(
submitterId: string | undefined,
itemUrl: string,
reportType: ReportType[],
reportComment: string
): Promise<void>
}
interface PubSubRequestMessage {
data: string
publishTime: string
}
export interface PubSubRequestBody {
message: PubSubRequestMessage
}
const expired = (body: PubSubRequestBody): boolean => {
const now = new Date()
const expiredTime = new Date(body.message.publishTime)
expiredTime.setHours(expiredTime.getHours() + 1)
return now > expiredTime
}
export const readPushSubscription = (
req: express.Request
): { message: string | undefined; expired: boolean } => {
if (req.query.token !== process.env.PUBSUB_VERIFICATION_TOKEN) {
logger.info('query does not include valid pubsub token')
return { message: undefined, expired: false }
}
// GCP PubSub sends the request as a base64 encoded string
if (!('message' in req.body)) {
logger.info('Invalid pubsub message: message not in body')
return { message: undefined, expired: false }
}
const body = req.body as PubSubRequestBody
const message = Buffer.from(body.message.data, 'base64').toString('utf-8')
return { message: message, expired: expired(body) }
}

View File

@@ -0,0 +1,173 @@
// Type definitions for non-npm package mozilla-readability 0.2
// Project: https://github.com/mozilla/readability
// Definitions by: Charles Vandevoorde <https://github.com/charlesvdv>, Alex Wendland <https://github.com/awendland>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// TypeScript Version: 2.2
declare module '@omnivore/readability' {
/**
* A standalone version of the readability library used for Firefox Reader View.
*
* Note that isProbablyReaderable() was moved into a separate file in https://github.com/mozilla/readability/commit/2620542dd1e8380220d82afa97a2c283ae636e40
* and therefore is no longer part of the Readability class.
*/
class Readability {
/**
* ## Usage on the web
*
* To parse a document, you must create a new Readability object from a
* DOM document object, and then call parse(). Here's an example:
*
* ```js
* var article = new Readability(document).parse();
* ```
*
* If you're using Readability on the web, you will likely be able to
* use a document reference from elsewhere (e.g. fetched via XMLHttpRequest,
* in a same-origin <iframe> you have access to, etc.).
*
* ## Usage from node.js
*
* In node.js, you won't generally have a DOM document object. To obtain one, you can use external
* libraries like [jsdom](https://github.com/tmpvar/jsdom). While this repository contains a parser of
* its own (`JSDOMParser`), that is restricted to reading XML-compatible markup and therefore we do
* not recommend it for general use.
*
* If you're using `jsdom` to create a DOM object, you should ensure that the page doesn't run (page)
* scripts (avoid fetching remote resources etc.) as well as passing it the page's URI as the `url`
* property of the `options` object you pass the `JSDOM` constructor.
*
* ```js
* var JSDOM = require('jsdom').JSDOM;
* var doc = new JSDOM("<body>Here's a bunch of text</body>", {
* url: "https://www.example.com/the-page-i-got-the-source-from",
* });
* let reader = new Readability(doc.window.document);
* let article = reader.parse();
* ```
*/
constructor(doc: Document, options?: Readability.Options)
/**
* Runs readability.
*
* ## Workflow:
*
* 1. Prep the document by removing script tags, css, etc.
* 2. Build readability's DOM tree.
* 3. Grab the article content from the current dom tree.
* 4. Replace the current DOM tree with the new one.
* 5. Read peacefully.
*
* ## Additional notes:
*
* Readability's parse() works by modifying the DOM. This removes some
* elements in the web page. You could avoid this by passing the clone
* of the document object while creating a Readability object.
*
* ```js
* var documentClone = document.cloneNode(true);
* var article = new Readability(documentClone).parse();
* ```
*
* The response will be null if the processing failed (https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L2038)
*/
async parse(): Promise<Readability.ParseResult | null>
}
namespace Readability {
interface Options {
/**
* Control whether log messages are sent to the console
*/
debug?: boolean
/**
* Set a maximum size on the documents that will be processed. This size is
* checked before any parsing operations occur. If the number of elements in
* the document exceeds this threshold then an Error will be thrown.
*
* See implementation details at https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L2019
*/
maxElemsToParse?: number
nbTopCandidates?: number
/**
* Minimum number of characters in the extracted textContent in order to
* consider the article correctly identified. If the threshold is not met then
* the extraction process will automatically run again with different flags.
*
* See implementation details at https://github.com/mozilla/readability/blob/52ab9b5c8916c306a47b2119270dcdabebf9d203/Readability.js#L1208
*
* Changed from wordThreshold in https://github.com/mozilla/readability/commit/3ff9a166fb27928f222c4c0722e730eda412658a
*/
charThreshold?: number
/**
* parse() removes the class="" attribute from every element in the given
* subtree, except those that match CLASSES_TO_PRESERVE and
* the classesToPreserve array from the options object.
*/
classesToPreserve?: string[]
/**
* By default Readability will strip all classes from the HTML elements in the
* processed article. By setting this to `true` the classes will be retained.
*
* This is a blanket alternative to `classesToPreserve`.
*
* Added in https://github.com/mozilla/readability/commit/2982216913af2c66b0690e88606b03116553ad92
*/
keepClasses?: boolean
url?: string
/**
* Function that converts a regular image url into imageproxy url
* @param url string
*/
createImageProxyUrl?: (
url: string,
width?: number,
height?: number
) => string
/**
* By default, Readability will clean all tables from the HTML elements in the
* processed article. But newsletters in emails use tables to display their content.
* By setting this to `true`, these tables will be retained.
*/
keepTables?: boolean
ignoreLinkDensity?: boolean
}
interface ParseResult {
/** Article title */
title: string
/** Author metadata */
byline?: string | null
/** Content direction */
dir?: string | null
/** HTML string of processed article content */
content: string
/** non-HTML version of `content` */
textContent: string
/** Length of an article, in characters */
length: number
/** Article description, or short excerpt from the content */
excerpt: string
/** Article site name */
siteName?: string | null
/** Article site icon */
siteIcon?: string | null
/** Article preview image */
previewImage?: string | null
/** Article published date */
publishedDate?: Date | null
language?: string | null
}
}
export { Readability }
}

View File

@@ -0,0 +1,33 @@
import { ILike } from 'typeorm'
import { appDataSource } from '../data_source'
import { Feed } from '../entity/feed'
export const feedRepository = appDataSource.getRepository(Feed).extend({
async searchFeeds(
query = '',
take = 10,
skip = 0,
orderBy = 'title',
order = 'ASC'
) {
const where = []
if (query !== '') {
query = `%${query}%`
where.push({ title: ILike(query) }, { url: ILike(query) })
}
const feeds = await this.find({
where,
order: { [orderBy]: order },
take,
skip,
})
const count = await this.countBy(where)
return {
feeds,
count,
}
},
})

View File

@@ -0,0 +1,53 @@
import { DeepPartial } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { appDataSource } from '../data_source'
import { Highlight } from '../entity/highlight'
import { unescapeHtml } from '../utils/helpers'
const unescapeHighlight = (highlight: DeepPartial<Highlight>) => {
// unescape HTML entities
if (highlight.annotation !== undefined && highlight.annotation !== null) {
highlight.annotation = unescapeHtml(highlight.annotation.toString())
}
if (highlight.quote !== undefined && highlight.quote !== null) {
highlight.quote = unescapeHtml(highlight.quote.toString())
}
return highlight
}
export const highlightRepository = appDataSource
.getRepository(Highlight)
.extend({
findById(id: string) {
return this.findOneBy({ id })
},
findByLibraryItemId(libraryItemId: string, userId: string) {
return this.findBy({
libraryItem: { id: libraryItemId },
user: { id: userId },
})
},
createAndSave(highlight: DeepPartial<Highlight>) {
return this.save(unescapeHighlight(highlight))
},
createAndSaves(highlights: DeepPartial<Highlight>[]) {
return this.save(highlights.map(unescapeHighlight))
},
updateAndSave(
highlightId: string,
highlight: QueryDeepPartialEntity<Highlight>
) {
if (highlight.annotation !== undefined && highlight.annotation !== null) {
highlight.annotation = unescapeHtml(highlight.annotation.toString())
}
if (highlight.quote !== undefined && highlight.quote !== null) {
highlight.quote = unescapeHtml(highlight.quote.toString())
}
return this.update(highlightId, highlight)
},
})

View File

@@ -0,0 +1,47 @@
import * as httpContext from 'express-http-context2'
import { EntityManager, EntityTarget, Repository } from 'typeorm'
import { appDataSource } from '../data_source'
import { Claims } from '../resolvers/types'
import { SetClaimsRole } from '../utils/dictionary'
export const getColumns = <T>(repository: Repository<T>): (keyof T)[] => {
return repository.metadata.columns.map(
(col) => col.propertyName
) as (keyof T)[]
}
export const setClaims = async (
manager: EntityManager,
uid = '00000000-0000-0000-0000-000000000000',
userRole = 'user'
): Promise<unknown> => {
const dbRole =
userRole === SetClaimsRole.ADMIN ? 'omnivore_admin' : 'omnivore_user'
return manager.query('SELECT * from omnivore.set_claims($1, $2)', [
uid,
dbRole,
])
}
export const authTrx = async <T>(
fn: (manager: EntityManager) => Promise<T>,
em = appDataSource.manager,
uid?: string,
userRole?: string
): Promise<T> => {
// if uid and dbRole are not passed in, then get them from the claims
if (!uid && !userRole) {
const claims: Claims | undefined = httpContext.get('claims')
uid = claims?.uid
userRole = claims?.userRole
}
return em.transaction(async (tx) => {
await setClaims(tx, uid, userRole)
return fn(tx)
})
}
export const getRepository = <T>(entity: EntityTarget<T>) => {
return appDataSource.getRepository(entity)
}

View File

@@ -0,0 +1,87 @@
import { In } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { appDataSource } from '../data_source'
import { Label } from '../entity/label'
import { generateRandomColor } from '../utils/helpers'
export interface CreateLabelInput {
name: string
color?: string | null
description?: string | null
}
const INTERNAL_LABELS_WITH_COLOR = new Map<
string,
{ name: string; color: string }
>([
['favorites', { name: 'Favorites', color: '#FFD700' }],
['library', { name: 'Library', color: '#584C42' }],
['rss', { name: 'RSS', color: '#F26522' }],
['newsletter', { name: 'Newsletter', color: '#07D2D1' }],
])
export const getInternalLabelWithColor = (name: string) => {
return INTERNAL_LABELS_WITH_COLOR.get(name.toLowerCase())
}
const isLabelInternal = (name: string): boolean => {
return INTERNAL_LABELS_WITH_COLOR.has(name.toLowerCase())
}
const convertToLabel = (label: CreateLabelInput, userId: string) => {
return {
user: { id: userId },
name: label.name,
color:
label.color ||
getInternalLabelWithColor(label.name)?.color ||
generateRandomColor(), // assign a random color if not provided
description: label.description,
internal: isLabelInternal(label.name),
}
}
export const labelRepository = appDataSource.getRepository(Label).extend({
findById(id: string) {
return this.findOneBy({ id })
},
findByName(name: string) {
return this.createQueryBuilder()
.where('LOWER(name) = LOWER(:name)', { name }) // case insensitive
.getOne()
},
findByNames(names: string[], userId: string) {
return this.createQueryBuilder()
.where('LOWER(name) IN (:...names)', {
names: names.map((n) => n.toLowerCase()),
})
.andWhere('user_id = :userId', { userId })
.getMany()
},
findLabelsById(labelIds: string[]) {
return this.find({
where: { id: In(labelIds) },
select: ['id', 'name', 'color', 'description', 'createdAt'],
})
},
createLabel(label: CreateLabelInput, userId: string) {
return this.save(convertToLabel(label, userId))
},
createLabels(labels: CreateLabelInput[], userId: string) {
return this.save(labels.map((l) => convertToLabel(l, userId)))
},
deleteById(id: string) {
return this.delete({ id, internal: false })
},
updateLabel(id: string, label: QueryDeepPartialEntity<Label>) {
// internal labels should not be updated
return this.update({ id, internal: false }, label)
},
})

View File

@@ -0,0 +1,62 @@
import { appDataSource } from '../data_source'
import { LibraryItem } from '../entity/library_item'
export const libraryItemRepository = appDataSource
.getRepository(LibraryItem)
.extend({
findById(id: string) {
return this.findOneBy({ id })
},
findByUrl(url: string) {
return this.findOneBy({
originalUrl: url,
})
},
countByCreatedAt(createdAt: Date) {
return this.countBy({ createdAt })
},
createByPopularRead(name: string, userId: string) {
return this.query(
`
INSERT INTO omnivore.library_item (
slug,
readable_content,
original_content,
description,
title,
author,
original_url,
item_type,
thumbnail,
published_at,
site_name,
user_id,
word_count
)
SELECT
slug,
readable_content,
original_content,
description,
title,
author,
original_url,
$1,
thumbnail,
published_at,
site_name,
$2,
word_count
FROM
omnivore.popular_read
WHERE
key = $3
RETURNING *
`,
['ARTICLE', userId, name]
) as Promise<LibraryItem[]>
},
})

View File

@@ -0,0 +1,37 @@
import { In } from 'typeorm'
import { appDataSource } from '../data_source'
import { StatusType, User } from './../entity/user'
const TOP_USERS = [
'jacksonh',
'nat',
'luis',
'satindar',
'malandrina',
'patrick',
'alexgutjahr',
'hongbowu',
]
export const MAX_RECORDS_LIMIT = 1000
export const userRepository = appDataSource.getRepository(User).extend({
findById(id: string) {
return this.findOneBy({ id, status: StatusType.Active })
},
findByEmail(email: string) {
return this.createQueryBuilder('user')
.leftJoinAndSelect('user.profile', 'profile')
.where('LOWER(email) = LOWER(:email)', { email }) // case insensitive
.getOne()
},
findTopUsers() {
return this.createQueryBuilder()
.where({
profile: { username: In(TOP_USERS) },
})
.take(MAX_RECORDS_LIMIT)
.getMany()
},
})

View File

@@ -0,0 +1,118 @@
import { ApiKey } from '../../entity/api_key'
import { env } from '../../env'
import {
ApiKeysError,
ApiKeysErrorCode,
ApiKeysSuccess,
GenerateApiKeyError,
GenerateApiKeyErrorCode,
GenerateApiKeySuccess,
MutationGenerateApiKeyArgs,
MutationRevokeApiKeyArgs,
RevokeApiKeyError,
RevokeApiKeyErrorCode,
RevokeApiKeySuccess,
} from '../../generated/graphql'
import { getRepository } from '../../repository'
import { findApiKeys } from '../../services/api_key'
import { analytics } from '../../utils/analytics'
import { generateApiKey, hashApiKey } from '../../utils/auth'
import { authorized } from '../../utils/helpers'
export const apiKeysResolver = authorized<ApiKeysSuccess, ApiKeysError>(
async (_, __, { log, uid }) => {
try {
const apiKeys = await findApiKeys(uid)
return {
apiKeys,
}
} catch (e) {
log.error('apiKeysResolver error', e)
return {
errorCodes: [ApiKeysErrorCode.BadRequest],
}
}
}
)
export const generateApiKeyResolver = authorized<
GenerateApiKeySuccess,
GenerateApiKeyError,
MutationGenerateApiKeyArgs
>(async (_, { input: { name, expiresAt } }, { log, uid }) => {
try {
const exp = new Date(expiresAt)
const originalKey = generateApiKey()
const apiKeyCreated = await getRepository(ApiKey).save({
user: { id: uid },
name,
key: hashApiKey(originalKey),
expiresAt: exp,
})
analytics.track({
userId: uid,
event: 'api_key_generated',
properties: {
name,
expiresAt: exp,
env: env.server.apiEnv,
},
})
return {
apiKey: {
...apiKeyCreated,
key: originalKey,
},
}
} catch (error) {
log.error('generateApiKeyResolver', error)
return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] }
}
})
export const revokeApiKeyResolver = authorized<
RevokeApiKeySuccess,
RevokeApiKeyError,
MutationRevokeApiKeyArgs
>(async (_, { id }, { claims: { uid }, log }) => {
try {
const apiRepo = getRepository(ApiKey)
const apiKey = await apiRepo.findOneBy({ id, user: { id: uid } })
if (!apiKey) {
return {
errorCodes: [RevokeApiKeyErrorCode.NotFound],
}
}
const deletedApiKey = await apiRepo.remove(apiKey)
analytics.track({
userId: uid,
event: 'api_key_revoked',
properties: {
id,
env: env.server.apiEnv,
},
})
return {
apiKey: {
...deletedApiKey,
id,
key: null,
},
}
} catch (e) {
log.error('revokeApiKeyResolver error', e)
return {
errorCodes: [RevokeApiKeyErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,990 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-floating-promises */
import { Readability } from '@omnivore/readability'
import graphqlFields from 'graphql-fields'
import { IsNull } from 'typeorm'
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import {
ArticleError,
ArticleErrorCode,
ArticleSuccess,
BulkActionError,
BulkActionErrorCode,
BulkActionSuccess,
BulkActionType,
ContentReader,
CreateArticleError,
CreateArticleErrorCode,
CreateArticleSuccess,
MoveToFolderError,
MoveToFolderErrorCode,
MoveToFolderSuccess,
MutationBulkActionArgs,
MutationCreateArticleArgs,
MutationMoveToFolderArgs,
MutationSaveArticleReadingProgressArgs,
MutationSetBookmarkArticleArgs,
MutationSetFavoriteArticleArgs,
PageType,
QueryArticleArgs,
QuerySearchArgs,
QueryTypeaheadSearchArgs,
QueryUpdatesSinceArgs,
SaveArticleReadingProgressError,
SaveArticleReadingProgressErrorCode,
SaveArticleReadingProgressSuccess,
SearchError,
SearchErrorCode,
SearchSuccess,
SetBookmarkArticleError,
SetBookmarkArticleSuccess,
SetFavoriteArticleError,
SetFavoriteArticleErrorCode,
SetFavoriteArticleSuccess,
TypeaheadSearchError,
TypeaheadSearchErrorCode,
TypeaheadSearchSuccess,
UpdateReason,
UpdatesSinceError,
UpdatesSinceSuccess,
} from '../../generated/graphql'
import { getColumns } from '../../repository'
import { getInternalLabelWithColor } from '../../repository/label'
import { libraryItemRepository } from '../../repository/library_item'
import { userRepository } from '../../repository/user'
import { createPageSaveRequest } from '../../services/create_page_save_request'
import { findHighlightsByLibraryItemId } from '../../services/highlights'
import {
addLabelsToLibraryItem,
createAndSaveLabelsInLibraryItem,
findLabelsByIds,
findOrCreateLabels,
} from '../../services/labels'
import {
createLibraryItem,
findLibraryItemByUrl,
findLibraryItemsByPrefix,
searchLibraryItems,
sortParamsToSort,
updateLibraryItem,
updateLibraryItemReadingProgress,
updateLibraryItems,
} from '../../services/library_item'
import { parsedContentToLibraryItem } from '../../services/save_page'
import {
findUploadFileById,
setFileUploadComplete,
} from '../../services/upload_file'
import { traceAs } from '../../tracing'
import { analytics } from '../../utils/analytics'
import { isSiteBlockedForParse } from '../../utils/blocked'
import {
authorized,
cleanUrl,
errorHandler,
generateSlug,
isParsingTimeout,
libraryItemToArticle,
libraryItemToSearchItem,
titleForFilePath,
userDataToUser,
} from '../../utils/helpers'
import {
contentConverter,
getDistillerResult,
htmlToMarkdown,
ParsedContentPuppeteer,
parsePreparedContent,
} from '../../utils/parser'
import { getStorageFileDetails } from '../../utils/uploads'
import { itemTypeForContentType } from '../upload_files'
export enum ArticleFormat {
Markdown = 'markdown',
Html = 'html',
Distiller = 'distiller',
HighlightedMarkdown = 'highlightedMarkdown',
}
// These two page types are better handled by the backend
// where we can use APIs to fetch their underlying content.
const FORCE_PUPPETEER_URLS = [
// twitter status url regex
/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)(?:\/.*)?/,
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w-]+\?v=|embed\/|v\/)?)([\w-]+)(\S+)?$/,
]
const UNPARSEABLE_CONTENT = '<p>We were unable to parse this page.</p>'
export const createArticleResolver = authorized<
CreateArticleSuccess,
CreateArticleError,
MutationCreateArticleArgs
>(
async (
_,
{
input: {
url,
preparedDocument,
articleSavingRequestId,
uploadFileId,
skipParsing,
source,
state,
labels: inputLabels,
folder,
rssFeedUrl,
savedAt,
publishedAt,
},
},
{ log, uid, pubsub }
) => {
analytics.track({
userId: uid,
event: 'link_saved',
properties: {
url,
source,
env: env.server.apiEnv,
},
})
const userData = await userRepository.findById(uid)
if (!userData) {
return errorHandler(
{
errorCodes: [CreateArticleErrorCode.Unauthorized],
},
uid,
articleSavingRequestId,
pubsub
)
}
const user = userDataToUser(userData)
try {
if (isSiteBlockedForParse(url)) {
return errorHandler(
{
errorCodes: [CreateArticleErrorCode.NotAllowedToParse],
},
uid,
articleSavingRequestId,
pubsub
)
}
url = cleanUrl(url)
const { pathname } = new URL(url)
const croppedPathname = decodeURIComponent(
pathname
.split('/')
[pathname.split('/').length - 1].split('.')
.slice(0, -1)
.join('.')
).replace(/_/gi, ' ')
let title: string | undefined
let parsedContent: Readability.ParseResult | null = null
let canonicalUrl
let uploadFileHash = null
let domContent = null
let itemType = PageType.Unknown
const DUMMY_RESPONSE: CreateArticleSuccess = {
user,
created: false,
createdArticle: {
id: '',
slug: '',
createdAt: new Date(),
originalHtml: domContent,
content: '',
description: '',
title: '',
pageType: itemType,
contentReader: ContentReader.Web,
author: '',
url,
hash: '',
isArchived: false,
readingProgressAnchorIndex: 0,
readingProgressPercent: 0,
highlights: [],
savedAt: savedAt || new Date(),
updatedAt: new Date(),
folder: '',
publishedAt,
subscription: rssFeedUrl,
},
}
if (uploadFileId) {
/* We do not trust the values from client, lookup upload file by querying
* with filtering on user ID and URL to verify client's uploadFileId is valid.
*/
const uploadFile = await findUploadFileById(uploadFileId)
if (!uploadFile) {
return errorHandler(
{ errorCodes: [CreateArticleErrorCode.UploadFileMissing] },
uid,
articleSavingRequestId,
pubsub
)
}
const uploadFileDetails = await getStorageFileDetails(
uploadFileId,
uploadFile.fileName
)
uploadFileHash = uploadFileDetails.md5Hash
canonicalUrl = uploadFile.url
itemType = itemTypeForContentType(uploadFile.contentType)
title = titleForFilePath(uploadFile.url)
} else if (
source !== 'puppeteer-parse' &&
FORCE_PUPPETEER_URLS.some((regex) => regex.test(url))
) {
await createPageSaveRequest({
userId: uid,
url,
state: state || undefined,
labels: inputLabels || undefined,
folder: folder || undefined,
savedAt,
publishedAt,
subscription: rssFeedUrl || undefined,
})
return DUMMY_RESPONSE
} else if (!skipParsing && preparedDocument?.document) {
const parseResults = await traceAs<Promise<ParsedContentPuppeteer>>(
{ spanName: 'article.parse' },
async (): Promise<ParsedContentPuppeteer> => {
return parsePreparedContent(url, preparedDocument)
}
)
parsedContent = parseResults.parsedContent
canonicalUrl = parseResults.canonicalUrl
domContent = parseResults.domContent
itemType = parseResults.pageType
} else if (!preparedDocument?.document) {
// We have a URL but no document, so we try to send this to puppeteer
// and return a dummy response.
await createPageSaveRequest({
userId: uid,
url,
state: state || undefined,
labels: inputLabels || undefined,
folder: folder || undefined,
savedAt,
publishedAt,
subscription: rssFeedUrl || undefined,
})
return DUMMY_RESPONSE
}
const slug = generateSlug(parsedContent?.title || croppedPathname)
const libraryItemToSave = parsedContentToLibraryItem({
url,
title,
parsedContent,
userId: uid,
slug,
croppedPathname,
originalHtml: domContent,
itemType,
preparedDocument,
uploadFileHash,
canonicalUrl,
uploadFileId,
state,
folder,
publishedAt,
rssFeedUrl,
savedAt,
})
log.info('New article saving', {
parsedArticle: Object.assign({}, libraryItemToSave, {
readableContent: undefined,
originalContent: undefined,
}),
})
if (uploadFileId) {
const uploadFileData = await setFileUploadComplete(uploadFileId)
if (!uploadFileData || !uploadFileData.id || !uploadFileData.fileName) {
return errorHandler(
{
errorCodes: [CreateArticleErrorCode.UploadFileMissing],
},
uid,
articleSavingRequestId,
pubsub
)
}
}
let libraryItemToReturn: LibraryItem
const existingLibraryItem = await findLibraryItemByUrl(
libraryItemToSave.originalUrl,
uid
)
articleSavingRequestId = existingLibraryItem?.id || articleSavingRequestId
if (articleSavingRequestId) {
// update existing item's state from processing to succeeded
libraryItemToReturn = await updateLibraryItem(
articleSavingRequestId,
libraryItemToSave as QueryDeepPartialEntity<LibraryItem>,
uid,
pubsub
)
} else {
// create new item in database
libraryItemToReturn = await createLibraryItem(
libraryItemToSave,
uid,
pubsub
)
}
await createAndSaveLabelsInLibraryItem(
libraryItemToReturn.id,
uid,
inputLabels,
rssFeedUrl
)
log.info(
'item created in database',
libraryItemToReturn.id,
libraryItemToReturn.originalUrl,
libraryItemToReturn.slug,
libraryItemToReturn.title
)
return {
user,
created: true,
createdArticle: libraryItemToArticle(libraryItemToReturn),
}
} catch (error) {
log.error('Error creating article', error)
return errorHandler(
{
errorCodes: [CreateArticleErrorCode.ElasticError],
},
uid,
articleSavingRequestId,
pubsub
)
}
}
)
export const getArticleResolver = authorized<
ArticleSuccess,
ArticleError,
QueryArticleArgs
>(async (_obj, { slug, format }, { authTrx, uid, log }, info) => {
try {
const selectColumns = getColumns(libraryItemRepository)
const includeOriginalHtml =
format === ArticleFormat.Distiller ||
!!graphqlFields(info).article.originalHtml
if (!includeOriginalHtml) {
selectColumns.splice(selectColumns.indexOf('originalContent'), 1)
}
// We allow the backend to use the ID instead of a slug to fetch the article
// query against id if slug is a uuid
const where = slug.match(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i)
? { id: slug }
: { slug }
const libraryItem = await authTrx((tx) =>
tx.withRepository(libraryItemRepository).findOne({
select: selectColumns,
where: {
...where,
deletedAt: IsNull(),
},
relations: {
labels: true,
highlights: {
user: true,
labels: true,
},
uploadFile: true,
recommendations: {
recommender: true,
group: true,
},
},
})
)
if (!libraryItem) {
return { errorCodes: [ArticleErrorCode.NotFound] }
}
if (isParsingTimeout(libraryItem)) {
libraryItem.readableContent = UNPARSEABLE_CONTENT
}
if (format === ArticleFormat.Markdown) {
libraryItem.readableContent = htmlToMarkdown(libraryItem.readableContent)
} else if (format === ArticleFormat.Distiller) {
if (!libraryItem.originalContent) {
return { errorCodes: [ArticleErrorCode.BadData] }
}
const distillerResult = await getDistillerResult(
uid,
libraryItem.originalContent
)
if (!distillerResult) {
return { errorCodes: [ArticleErrorCode.BadData] }
}
libraryItem.readableContent = distillerResult
}
return {
article: libraryItemToArticle(libraryItem),
}
} catch (error) {
log.error(error)
return { errorCodes: [ArticleErrorCode.BadData] }
}
})
// type PaginatedPartialArticles = {
// edges: { cursor: string; node: PartialArticle }[]
// pageInfo: PageInfo
// }
// export type SetShareArticleSuccessPartial = Merge<
// SetShareArticleSuccess,
// {
// updatedFeedArticle?: Omit<
// FeedArticle,
// | 'sharedBy'
// | 'article'
// | 'highlightsCount'
// | 'annotationsCount'
// | 'reactions'
// >
// updatedFeedArticleId?: string
// updatedArticle: PartialArticle
// }
// >
// export const setShareArticleResolver = authorized<
// SetShareArticleSuccessPartial,
// SetShareArticleError,
// MutationSetShareArticleArgs
// >(
// async (
// _,
// { input: { articleID, share, sharedComment, sharedWithHighlights } },
// { models, authTrx, claims: { uid }, log }
// ) => {
// const article = await models.article.get(articleID)
// if (!article) {
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
// }
// const sharedAt = share ? new Date() : null
// log.info(`${share ? 'S' : 'Uns'}haring an article`, {
// article: Object.assign({}, article, {
// content: undefined,
// originalHtml: undefined,
// sharedAt,
// }),
// labels: {
// source: 'resolver',
// resolver: 'setShareArticleResolver',
// articleId: article.id,
// userId: uid,
// },
// })
// const result = await authTrx((tx) =>
// models.userArticle.updateByArticleId(
// uid,
// articleID,
// { sharedAt, sharedComment, sharedWithHighlights },
// tx
// )
// )
// if (!result) {
// return { errorCodes: [SetShareArticleErrorCode.NotFound] }
// }
// // Make sure article.id instead of userArticle.id has passed. We use it for cache updates
// const updatedArticle = {
// ...result,
// ...article,
// postedByViewer: !!sharedAt,
// }
// const updatedFeedArticle = sharedAt ? { ...result, sharedAt } : undefined
// return {
// updatedFeedArticleId: result.id,
// updatedFeedArticle,
// updatedArticle,
// }
// }
// )
export const setBookmarkArticleResolver = authorized<
SetBookmarkArticleSuccess,
SetBookmarkArticleError,
MutationSetBookmarkArticleArgs
>(async (_, { input: { articleID } }, { uid, log, pubsub }) => {
// delete the item and its metadata
const deletedLibraryItem = await updateLibraryItem(
articleID,
{
state: LibraryItemState.Deleted,
deletedAt: new Date(),
},
uid,
pubsub
)
analytics.track({
userId: uid,
event: 'link_removed',
properties: {
id: articleID,
env: env.server.apiEnv,
},
})
log.info('Article unbookmarked', {
item: Object.assign({}, deletedLibraryItem, {
readableContent: undefined,
originalContent: undefined,
}),
})
// Make sure article.id instead of userArticle.id has passed. We use it for cache updates
return {
bookmarkedArticle: libraryItemToArticle(deletedLibraryItem),
}
})
export const saveArticleReadingProgressResolver = authorized<
SaveArticleReadingProgressSuccess,
SaveArticleReadingProgressError,
MutationSaveArticleReadingProgressArgs
>(
async (
_,
{
input: {
id,
readingProgressPercent,
readingProgressAnchorIndex,
readingProgressTopPercent,
force,
},
},
{ log, pubsub, uid }
) => {
if (
readingProgressPercent < 0 ||
readingProgressPercent > 100 ||
(readingProgressTopPercent &&
(readingProgressTopPercent < 0 ||
readingProgressTopPercent > readingProgressPercent)) ||
(readingProgressAnchorIndex && readingProgressAnchorIndex < 0)
) {
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
}
try {
if (force) {
// update reading progress without checking the current value
const updatedItem = await updateLibraryItem(
id,
{
readingProgressBottomPercent: readingProgressPercent,
readingProgressTopPercent: readingProgressTopPercent ?? undefined,
readingProgressHighestReadAnchor:
readingProgressAnchorIndex ?? undefined,
readAt: new Date(),
},
uid,
pubsub
)
return {
updatedArticle: libraryItemToArticle(updatedItem),
}
}
// update reading progress only if the current value is lower
const updatedItem = await updateLibraryItemReadingProgress(
id,
uid,
readingProgressPercent,
readingProgressTopPercent,
readingProgressAnchorIndex,
pubsub
)
if (!updatedItem) {
return { errorCodes: [SaveArticleReadingProgressErrorCode.BadData] }
}
return {
updatedArticle: libraryItemToArticle(updatedItem),
}
} catch (error) {
log.error('saveArticleReadingProgressResolver error', error)
return { errorCodes: [SaveArticleReadingProgressErrorCode.Unauthorized] }
}
}
)
export const searchResolver = authorized<
SearchSuccess,
SearchError,
QuerySearchArgs
>(async (_obj, params, { log, uid }) => {
const startCursor = params.after || ''
const first = Math.min(params.first || 10, 100) // limit to 100 items
// the query size is limited to 255 characters
if (params.query && params.query.length > 255) {
return { errorCodes: [SearchErrorCode.QueryTooLong] }
}
const { libraryItems, count } = await searchLibraryItems(
{
from: Number(startCursor),
size: first + 1, // fetch one more item to get next cursor
includePending: true,
includeContent: !!params.includeContent,
includeDeleted: params.query?.includes('in:trash'),
query: params.query,
useFolders: params.query?.includes('use:folders'),
},
uid
)
const start =
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
const hasNextPage = libraryItems.length > first
const endCursor = String(start + libraryItems.length - (hasNextPage ? 1 : 0))
if (hasNextPage) {
// remove an extra if exists
libraryItems.pop()
}
const edges = await Promise.all(
libraryItems.map(async (libraryItem) => {
if (
libraryItem.highlightAnnotations &&
libraryItem.highlightAnnotations.length > 0
) {
libraryItem.highlights = await findHighlightsByLibraryItemId(
libraryItem.id,
uid
)
}
if (params.includeContent && libraryItem.readableContent) {
// convert html to the requested format
const format = params.format || ArticleFormat.Html
try {
const converter = contentConverter(format)
if (converter) {
libraryItem.readableContent = converter(
libraryItem.readableContent,
libraryItem.highlights
)
}
} catch (error) {
log.error('Error converting content', error)
}
}
return {
node: libraryItemToSearchItem(libraryItem),
cursor: endCursor,
}
})
)
return {
edges,
pageInfo: {
hasPreviousPage: false,
startCursor,
hasNextPage,
endCursor,
totalCount: count,
},
}
})
export const typeaheadSearchResolver = authorized<
TypeaheadSearchSuccess,
TypeaheadSearchError,
QueryTypeaheadSearchArgs
>(async (_obj, { query, first }, { log, uid }) => {
try {
const items = await findLibraryItemsByPrefix(query, uid, first || undefined)
return {
items: items.map((item) => ({
...item,
contentReader: item.contentReader as unknown as ContentReader,
})),
}
} catch (error) {
log.error('typeaheadSearchResolver error', error)
return { errorCodes: [TypeaheadSearchErrorCode.Unauthorized] }
}
})
export const updatesSinceResolver = authorized<
UpdatesSinceSuccess,
UpdatesSinceError,
QueryUpdatesSinceArgs
>(async (_obj, { since, first, after, sort: sortParams, folder }, { uid }) => {
const startCursor = after || ''
const size = Math.min(first || 10, 100) // limit to 100 items
let startDate = new Date(since)
if (isNaN(startDate.getTime())) {
// for android app compatibility
startDate = new Date(0)
}
const sort = sortParamsToSort(sortParams)
// create a search query
const query = `updated:${startDate.toISOString()}${
folder ? ' in:' + folder : ''
} sort:${sort.by}-${sort.order}`
const { libraryItems, count } = await searchLibraryItems(
{
from: Number(startCursor),
size: size + 1, // fetch one more item to get next cursor
includeDeleted: true,
query,
},
uid
)
const start =
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
const hasNextPage = libraryItems.length > size
const endCursor = String(start + libraryItems.length - (hasNextPage ? 1 : 0))
//TODO: refactor so that the lastCursor included
if (hasNextPage) {
// remove an extra if exists
libraryItems.pop()
}
const edges = libraryItems.map((item) => {
const updateReason = getUpdateReason(item, startDate)
return {
node: libraryItemToSearchItem(item),
cursor: endCursor,
itemID: item.id,
updateReason,
}
})
return {
edges,
pageInfo: {
hasPreviousPage: false,
startCursor,
hasNextPage,
endCursor,
totalCount: count,
},
}
})
export const bulkActionResolver = authorized<
BulkActionSuccess,
BulkActionError,
MutationBulkActionArgs
>(
async (
_parent,
{ query, action, labelIds, arguments: args }, // arguments is a reserved keyword in JS
{ uid, log }
) => {
try {
analytics.track({
userId: uid,
event: 'BulkAction',
properties: {
env: env.server.apiEnv,
action,
},
})
// the query size is limited to 4000 characters to allow for 100 items
if (!query || query.length > 4000) {
log.error('bulkActionResolver error', {
error: 'QueryTooLong',
query,
})
return { errorCodes: [BulkActionErrorCode.BadRequest] }
}
// get labels if needed
let labels = undefined
if (action === BulkActionType.AddLabels) {
if (!labelIds || labelIds.length === 0) {
return { errorCodes: [BulkActionErrorCode.BadRequest] }
}
labels = await findLabelsByIds(labelIds, uid)
}
await updateLibraryItems(
action,
{
query,
useFolders: query.includes('use:folders'),
},
uid,
labels,
args
)
return { success: true }
} catch (error) {
log.error('bulkActionResolver error', error)
return { errorCodes: [BulkActionErrorCode.BadRequest] }
}
}
)
export const setFavoriteArticleResolver = authorized<
SetFavoriteArticleSuccess,
SetFavoriteArticleError,
MutationSetFavoriteArticleArgs
>(async (_, { id }, { uid, log }) => {
try {
analytics.track({
userId: uid,
event: 'setFavoriteArticle',
properties: {
env: env.server.apiEnv,
id,
},
})
const label = getInternalLabelWithColor('Favorites')
if (!label) {
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
}
const labels = await findOrCreateLabels([label], uid)
// adds Favorites label to item
await addLabelsToLibraryItem(labels, id, uid)
return {
success: true,
}
} catch (error) {
log.info('Error adding Favorites label', error)
return { errorCodes: [SetFavoriteArticleErrorCode.BadRequest] }
}
})
export const moveToFolderResolver = authorized<
MoveToFolderSuccess,
MoveToFolderError,
MutationMoveToFolderArgs
>(async (_, { id, folder }, { authTrx, pubsub, uid }) => {
analytics.track({
userId: uid,
event: 'move_to_folder',
properties: {
id,
folder,
},
})
const item = await authTrx((tx) =>
tx.getRepository(LibraryItem).findOne({
where: {
id,
},
relations: ['user'],
})
)
if (!item) {
return {
errorCodes: [MoveToFolderErrorCode.Unauthorized],
}
}
if (item.folder === folder) {
return {
errorCodes: [MoveToFolderErrorCode.AlreadyExists],
}
}
const savedAt = new Date()
// // if the content is not fetched yet, create a page save request
// if (!item.readableContent) {
// const articleSavingRequest = await createPageSaveRequest({
// userId: uid,
// url: item.originalUrl,
// articleSavingRequestId: id,
// priority: 'high',
// publishedAt: item.publishedAt || undefined,
// savedAt,
// pubsub,
// })
// return {
// __typename: 'MoveToFolderSuccess',
// articleSavingRequest,
// }
// }
await updateLibraryItem(
item.id,
{
folder,
savedAt,
},
uid,
pubsub
)
return {
__typename: 'MoveToFolderSuccess',
success: true,
}
})
const getUpdateReason = (libraryItem: LibraryItem, since: Date) => {
if (libraryItem.deletedAt) {
return UpdateReason.Deleted
}
if (libraryItem.createdAt >= since) {
return UpdateReason.Created
}
return UpdateReason.Updated
}

View File

@@ -0,0 +1,101 @@
/* eslint-disable prefer-const */
import { LibraryItem, LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import {
ArticleSavingRequestError,
ArticleSavingRequestErrorCode,
ArticleSavingRequestSuccess,
CreateArticleSavingRequestError,
CreateArticleSavingRequestErrorCode,
CreateArticleSavingRequestSuccess,
MutationCreateArticleSavingRequestArgs,
QueryArticleSavingRequestArgs,
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { createPageSaveRequest } from '../../services/create_page_save_request'
import {
findLibraryItemById,
findLibraryItemByUrl,
} from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import {
authorized,
cleanUrl,
isParsingTimeout,
libraryItemToArticleSavingRequest,
} from '../../utils/helpers'
import { isErrorWithCode } from '../user'
export const createArticleSavingRequestResolver = authorized<
CreateArticleSavingRequestSuccess,
CreateArticleSavingRequestError,
MutationCreateArticleSavingRequestArgs
>(async (_, { input: { url } }, { uid, pubsub, log }) => {
analytics.track({
userId: uid,
event: 'link_saved',
properties: {
url: url,
method: 'article_saving_request',
env: env.server.apiEnv,
},
})
try {
const articleSavingRequest = await createPageSaveRequest({
userId: uid,
url,
pubsub,
})
return {
articleSavingRequest,
}
} catch (err) {
log.error('createArticleSavingRequestResolver error', err)
if (isErrorWithCode(err)) {
return {
errorCodes: [err.errorCode as CreateArticleSavingRequestErrorCode],
}
}
return { errorCodes: [CreateArticleSavingRequestErrorCode.BadData] }
}
})
export const articleSavingRequestResolver = authorized<
ArticleSavingRequestSuccess,
ArticleSavingRequestError,
QueryArticleSavingRequestArgs
>(async (_, { id, url }, { uid, log }) => {
try {
if (!id && !url) {
return { errorCodes: [ArticleSavingRequestErrorCode.BadData] }
}
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [ArticleSavingRequestErrorCode.Unauthorized] }
}
let libraryItem: LibraryItem | null = null
if (id) {
libraryItem = await findLibraryItemById(id, uid)
} else if (url) {
libraryItem = await findLibraryItemByUrl(cleanUrl(url), uid)
}
if (!libraryItem) {
return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] }
}
if (isParsingTimeout(libraryItem)) {
libraryItem.state = LibraryItemState.Succeeded
}
return {
articleSavingRequest: libraryItemToArticleSavingRequest(
user,
libraryItem
),
}
} catch (error) {
log.error('articleSavingRequestResolver error', error)
return { errorCodes: [ArticleSavingRequestErrorCode.NotFound] }
}
})

View File

@@ -0,0 +1,61 @@
import {
MutationOptInFeatureArgs,
OptInFeatureError,
OptInFeatureErrorCode,
OptInFeatureSuccess,
} from '../../generated/graphql'
import {
getFeatureName,
optInFeature,
signFeatureToken,
} from '../../services/features'
import { authorized } from '../../utils/helpers'
export const optInFeatureResolver = authorized<
OptInFeatureSuccess,
OptInFeatureError,
MutationOptInFeatureArgs
>(async (_, { input: { name } }, { claims, log }) => {
log.info('Opting in to a feature', {
feature: name,
labels: {
source: 'resolver',
resolver: 'optInFeatureResolver',
uid: claims.uid,
},
})
try {
const featureName = getFeatureName(name)
if (!featureName) {
return {
errorCodes: [OptInFeatureErrorCode.NotFound],
}
}
const optedInFeature = await optInFeature(featureName, claims.uid)
if (!optedInFeature) {
return {
errorCodes: [OptInFeatureErrorCode.NotFound],
}
}
log.info('Opted in to a feature', optedInFeature)
const token = signFeatureToken(optedInFeature, claims.uid)
return {
feature: {
...optedInFeature,
token,
},
}
} catch (e) {
log.error('Error opting in to a feature', {
error: e,
})
return {
errorCodes: [OptInFeatureErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,267 @@
import { isNil, mergeWith } from 'lodash'
import { Between } from 'typeorm'
import { Filter } from '../../entity/filter'
import { env } from '../../env'
import {
DeleteFilterError,
DeleteFilterErrorCode,
DeleteFilterSuccess,
FiltersError,
FiltersErrorCode,
FiltersSuccess,
MoveFilterError,
MoveFilterErrorCode,
MoveFilterSuccess,
MutationDeleteFilterArgs,
MutationMoveFilterArgs,
MutationSaveFilterArgs,
MutationUpdateFilterArgs,
SaveFilterError,
SaveFilterErrorCode,
SaveFilterSuccess,
UpdateFilterError,
UpdateFilterErrorCode,
UpdateFilterSuccess,
} from '../../generated/graphql'
import { authTrx } from '../../repository'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const saveFilterResolver = authorized<
SaveFilterSuccess,
SaveFilterError,
MutationSaveFilterArgs
>(async (_, { input }, { authTrx, log, uid }) => {
try {
const filter = await authTrx(async (t) => {
return t.getRepository(Filter).save({
user: { id: uid },
name: input.name,
folder: input.folder ?? 'inbox',
description: '',
position: input.position ?? 0,
filter: input.filter,
defaultFilter: false,
visible: true,
category: input.category ?? 'Search',
})
})
return {
filter,
}
} catch (error) {
log.error('Error saving filters', error)
return {
errorCodes: [SaveFilterErrorCode.BadRequest],
}
}
})
export const deleteFilterResolver = authorized<
DeleteFilterSuccess,
DeleteFilterError,
MutationDeleteFilterArgs
>(async (_, { id }, { authTrx, log }) => {
try {
const filter = await authTrx(async (t) => {
const repo = t.getRepository(Filter)
const filter = await repo.findOneByOrFail({
id,
})
await repo.delete(filter.id)
return filter
})
return {
filter,
}
} catch (error) {
log.error('Error deleting filters', error)
return {
errorCodes: [DeleteFilterErrorCode.BadRequest],
}
}
})
export const filtersResolver = authorized<FiltersSuccess, FiltersError>(
async (_, __, { authTrx, uid, log }) => {
try {
const filters = await authTrx((t) =>
t.getRepository(Filter).find({
where: { user: { id: uid } },
order: { position: 'ASC' },
})
)
return {
filters,
}
} catch (error) {
log.error('Error getting filters', error)
return {
errorCodes: [FiltersErrorCode.BadRequest],
}
}
}
)
const updatePosition = async (
uid: string,
filter: Filter,
newPosition: number
) => {
const { position } = filter
const moveUp = newPosition < position
// move filter to the new position
const updated = await authTrx(async (t) => {
const repo = t.getRepository(Filter)
// update the position of the other filters
const updated = await repo.update(
{
user: { id: uid },
position: Between(
Math.min(newPosition, position),
Math.max(newPosition, position)
),
},
{
position: () => `position + ${moveUp ? 1 : -1}`,
}
)
if (!updated.affected) {
return null
}
// update the position of the filter
return repo.save({
...filter,
position: newPosition,
})
})
if (!updated) {
throw new Error('unable to update')
}
return updated
}
export const updateFilterResolver = authorized<
UpdateFilterSuccess,
UpdateFilterError,
MutationUpdateFilterArgs
>(async (_, { input }, { authTrx, log, uid }) => {
const { id } = input
try {
const filter = await authTrx((t) =>
t.getRepository(Filter).findOneBy({ id })
)
if (!filter) {
return {
__typename: 'UpdateFilterError',
errorCodes: [UpdateFilterErrorCode.NotFound],
}
}
if (!isNil(input.position) && filter.position != input.position) {
await updatePosition(uid, filter, input.position)
}
const updated = await authTrx((t) =>
t.getRepository(Filter).save({
...mergeWith({}, filter, input, (a: unknown, b: unknown) =>
isNil(b) ? a : undefined
),
})
)
return {
__typename: 'UpdateFilterSuccess',
filter: updated,
}
} catch (error) {
log.error('Error Updating filters', error)
return {
__typename: 'UpdateFilterError',
errorCodes: [UpdateFilterErrorCode.BadRequest],
}
}
})
export const moveFilterResolver = authorized<
MoveFilterSuccess,
MoveFilterError,
MutationMoveFilterArgs
>(async (_, { input }, { authTrx, uid, log }) => {
const { filterId, afterFilterId } = input
try {
const filter = await authTrx((t) =>
t.getRepository(Filter).findOneBy({
id: filterId,
})
)
if (!filter) {
return {
errorCodes: [MoveFilterErrorCode.NotFound],
}
}
if (filter.id === afterFilterId) {
// nothing to do
return { filter }
}
// if afterFilterId is not provided, move to the top
let newPosition = 0
if (afterFilterId) {
const afterFilter = await authTrx((t) =>
t.getRepository(Filter).findOneBy({
id: afterFilterId,
})
)
if (!afterFilter) {
return {
errorCodes: [MoveFilterErrorCode.NotFound],
}
}
newPosition = afterFilter.position
}
const updated = await updatePosition(uid, filter, newPosition)
if (!updated) {
return {
errorCodes: [MoveFilterErrorCode.BadRequest],
}
}
analytics.track({
userId: uid,
event: 'filter_moved',
properties: {
filterId,
afterFilterId,
env: env.server.apiEnv,
},
})
return {
filter: updated,
}
} catch (error) {
log.error('Error moving filters', error)
return {
errorCodes: [MoveFilterErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,521 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { createHmac } from 'crypto'
import { Subscription } from '../entity/subscription'
import { env } from '../env'
import {
Article,
Label,
PageType,
Recommendation,
SearchItem,
User,
} from '../generated/graphql'
import { findLabelsByLibraryItemId } from '../services/labels'
import { findRecommendationsByLibraryItemId } from '../services/recommendation'
import { findUploadFileById } from '../services/upload_file'
import {
isBase64Image,
recommandationDataToRecommendation,
validatedDate,
wordsCount,
} from '../utils/helpers'
import { createImageProxyUrl } from '../utils/imageproxy'
import {
generateDownloadSignedUrl,
generateUploadFilePathName,
} from '../utils/uploads'
import { optInFeatureResolver } from './features'
import { uploadImportFileResolver } from './importers/uploadImportFileResolver'
import {
addPopularReadResolver,
apiKeysResolver,
articleSavingRequestResolver,
bulkActionResolver,
createArticleResolver,
createArticleSavingRequestResolver,
createGroupResolver,
createHighlightResolver,
createLabelResolver,
createNewsletterEmailResolver,
// createReminderResolver,
deleteAccountResolver,
deleteFilterResolver,
deleteHighlightResolver,
deleteIntegrationResolver,
deleteLabelResolver,
deleteNewsletterEmailResolver,
// deleteReminderResolver,
deleteRuleResolver,
deleteWebhookResolver,
deviceTokensResolver,
feedsResolver,
filtersResolver,
generateApiKeyResolver,
getAllUsersResolver,
getArticleResolver,
// getFollowersResolver,
// getFollowingResolver,
getMeUserResolver,
// getSharedArticleResolver,
// getUserFeedArticlesResolver,
getUserPersonalizationResolver,
getUserResolver,
googleLoginResolver,
googleSignupResolver,
groupsResolver,
importFromIntegrationResolver,
integrationsResolver,
joinGroupResolver,
labelsResolver,
leaveGroupResolver,
logOutResolver,
mergeHighlightResolver,
moveFilterResolver,
moveLabelResolver,
moveToFolderResolver,
newsletterEmailsResolver,
recommendHighlightsResolver,
recommendResolver,
// reminderResolver,
reportItemResolver,
revokeApiKeyResolver,
rulesResolver,
saveArticleReadingProgressResolver,
saveFileResolver,
saveFilterResolver,
savePageResolver,
saveUrlResolver,
scanFeedsResolver,
searchResolver,
sendInstallInstructionsResolver,
setBookmarkArticleResolver,
setDeviceTokenResolver,
setFavoriteArticleResolver,
// setFollowResolver,
setIntegrationResolver,
setLabelsForHighlightResolver,
setLabelsResolver,
setLinkArchivedResolver,
setRuleResolver,
// setShareArticleResolver,
// setShareHighlightResolver,
setUserPersonalizationResolver,
setWebhookResolver,
subscribeResolver,
subscriptionsResolver,
typeaheadSearchResolver,
unsubscribeResolver,
updateFilterResolver,
updateHighlightResolver,
updateLabelResolver,
// updateLinkShareInfoResolver,
updatePageResolver,
// updateReminderResolver,
// updateSharedCommentResolver,
updatesSinceResolver,
updateSubscriptionResolver,
updateUserProfileResolver,
updateUserResolver,
uploadFileRequestResolver,
validateUsernameResolver,
webhookResolver,
webhooksResolver,
} from './index'
import { markEmailAsItemResolver, recentEmailsResolver } from './recent_emails'
import { recentSearchesResolver } from './recent_searches'
import { WithDataSourcesContext } from './types'
import { updateEmailResolver } from './user'
/* eslint-disable @typescript-eslint/naming-convention */
type ResultResolveType = {
[x: string]: {
__resolveType: (obj: { errorCodes: string[] | undefined }) => string
}
}
const resultResolveTypeResolver = (
resolverName: string
): ResultResolveType => ({
[`${resolverName}Result`]: {
__resolveType: (obj) =>
obj.errorCodes ? `${resolverName}Error` : `${resolverName}Success`,
},
})
// Provide resolver functions for your schema fields
export const functionResolvers = {
Mutation: {
googleLogin: googleLoginResolver,
googleSignup: googleSignupResolver,
logOut: logOutResolver,
deleteAccount: deleteAccountResolver,
saveArticleReadingProgress: saveArticleReadingProgressResolver,
updateUser: updateUserResolver,
updateUserProfile: updateUserProfileResolver,
createArticle: createArticleResolver,
createHighlight: createHighlightResolver,
// createReaction: createReactionResolver,
// deleteReaction: deleteReactionResolver,
mergeHighlight: mergeHighlightResolver,
updateHighlight: updateHighlightResolver,
deleteHighlight: deleteHighlightResolver,
uploadFileRequest: uploadFileRequestResolver,
// setShareArticle: setShareArticleResolver,
// updateSharedComment: updateSharedCommentResolver,
// setFollow: setFollowResolver,
setBookmarkArticle: setBookmarkArticleResolver,
setUserPersonalization: setUserPersonalizationResolver,
createArticleSavingRequest: createArticleSavingRequestResolver,
// setShareHighlight: setShareHighlightResolver,
reportItem: reportItemResolver,
// updateLinkShareInfo: updateLinkShareInfoResolver,
setLinkArchived: setLinkArchivedResolver,
createNewsletterEmail: createNewsletterEmailResolver,
deleteNewsletterEmail: deleteNewsletterEmailResolver,
saveUrl: saveUrlResolver,
savePage: savePageResolver,
saveFile: saveFileResolver,
// createReminder: createReminderResolver,
// updateReminder: updateReminderResolver,
// deleteReminder: deleteReminderResolver,
setDeviceToken: setDeviceTokenResolver,
createLabel: createLabelResolver,
updateLabel: updateLabelResolver,
deleteLabel: deleteLabelResolver,
setLabels: setLabelsResolver,
generateApiKey: generateApiKeyResolver,
unsubscribe: unsubscribeResolver,
updatePage: updatePageResolver,
subscribe: subscribeResolver,
addPopularRead: addPopularReadResolver,
setWebhook: setWebhookResolver,
deleteWebhook: deleteWebhookResolver,
revokeApiKey: revokeApiKeyResolver,
setLabelsForHighlight: setLabelsForHighlightResolver,
moveLabel: moveLabelResolver,
setIntegration: setIntegrationResolver,
deleteIntegration: deleteIntegrationResolver,
optInFeature: optInFeatureResolver,
setRule: setRuleResolver,
deleteRule: deleteRuleResolver,
saveFilter: saveFilterResolver,
deleteFilter: deleteFilterResolver,
moveFilter: moveFilterResolver,
createGroup: createGroupResolver,
recommend: recommendResolver,
joinGroup: joinGroupResolver,
recommendHighlights: recommendHighlightsResolver,
leaveGroup: leaveGroupResolver,
uploadImportFile: uploadImportFileResolver,
markEmailAsItem: markEmailAsItemResolver,
bulkAction: bulkActionResolver,
importFromIntegration: importFromIntegrationResolver,
setFavoriteArticle: setFavoriteArticleResolver,
updateSubscription: updateSubscriptionResolver,
updateFilter: updateFilterResolver,
updateEmail: updateEmailResolver,
moveToFolder: moveToFolderResolver,
},
Query: {
me: getMeUserResolver,
user: getUserResolver,
users: getAllUsersResolver,
validateUsername: validateUsernameResolver,
article: getArticleResolver,
// sharedArticle: getSharedArticleResolver,
// feedArticles: getUserFeedArticlesResolver,
// getFollowers: getFollowersResolver,
// getFollowing: getFollowingResolver,
getUserPersonalization: getUserPersonalizationResolver,
articleSavingRequest: articleSavingRequestResolver,
newsletterEmails: newsletterEmailsResolver,
// reminder: reminderResolver,
labels: labelsResolver,
search: searchResolver,
subscriptions: subscriptionsResolver,
sendInstallInstructions: sendInstallInstructionsResolver,
webhooks: webhooksResolver,
webhook: webhookResolver,
apiKeys: apiKeysResolver,
typeaheadSearch: typeaheadSearchResolver,
updatesSince: updatesSinceResolver,
integrations: integrationsResolver,
recentSearches: recentSearchesResolver,
rules: rulesResolver,
deviceTokens: deviceTokensResolver,
filters: filtersResolver,
groups: groupsResolver,
recentEmails: recentEmailsResolver,
feeds: feedsResolver,
scanFeeds: scanFeedsResolver,
},
User: {
async intercomHash(
user: User,
__: Record<string, unknown>,
ctx: WithDataSourcesContext
) {
if (env.intercom.secretKey) {
const userIdentifier = user.id.toString()
return createHmac('sha256', env.intercom.secretKey)
.update(userIdentifier)
.digest('hex')
}
return undefined
},
},
Article: {
async url(article: Article, _: unknown, ctx: WithDataSourcesContext) {
if (
(article.pageType == PageType.File ||
article.pageType == PageType.Book) &&
ctx.claims &&
article.uploadFileId
) {
const upload = await findUploadFileById(article.uploadFileId)
if (!upload || !upload.fileName) {
return undefined
}
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
return generateDownloadSignedUrl(filePath)
}
return article.url
},
originalArticleUrl(article: { url: string }) {
return article.url
},
hasContent(article: {
content: string | null
originalHtml: string | null
}) {
return !!article.originalHtml && !!article.content
},
publishedAt(article: { publishedAt: Date }) {
return validatedDate(article.publishedAt)
},
// async shareInfo(
// article: { id: string; sharedBy?: User; shareInfo?: LinkShareInfo },
// __: unknown,
// ctx: WithDataSourcesContext
// ): Promise<LinkShareInfo | undefined> {
// if (article.shareInfo) return article.shareInfo
// if (!ctx.claims?.uid) return undefined
// return getShareInfoForArticle(
// ctx.kx,
// ctx.claims?.uid,
// article.id,
// ctx.models
// )
// },
image(article: { image?: string }): string | undefined {
return article.image && createImageProxyUrl(article.image, 320, 320)
},
wordsCount(article: { wordCount?: number; content?: string }) {
if (article.wordCount) return article.wordCount
return article.content ? wordsCount(article.content) : undefined
},
},
Highlight: {
// async reactions(
// highlight: { id: string; reactions?: Reaction[] },
// _: unknown,
// ctx: WithDataSourcesContext
// ) {
// const { reactions, id } = highlight
// if (reactions) return reactions
// return await ctx.models.reaction.batchGetFromHighlight(id)
// },
createdByMe(
highlight: { user: { id: string } },
__: unknown,
ctx: WithDataSourcesContext
) {
return highlight.user.id === ctx.uid
},
},
// Reaction: {
// async user(
// reaction: { userId: string },
// __: unknown,
// ctx: WithDataSourcesContext
// ) {
// return userDataToUser(await ctx.models.user.get(reaction.userId))
// },
// },
SearchItem: {
async url(item: SearchItem, _: unknown, ctx: WithDataSourcesContext) {
if (
(item.pageType == PageType.File || item.pageType == PageType.Book) &&
ctx.claims &&
item.uploadFileId
) {
const upload = await findUploadFileById(item.uploadFileId)
if (!upload || !upload.fileName) {
return undefined
}
const filePath = generateUploadFilePathName(upload.id, upload.fileName)
return generateDownloadSignedUrl(filePath)
}
return item.url
},
image(item: SearchItem) {
return item.image && createImageProxyUrl(item.image, 320, 320)
},
originalArticleUrl(item: { url: string }) {
return item.url
},
wordsCount(item: { wordCount?: number; content?: string }) {
if (item.wordCount) return item.wordCount
return item.content ? wordsCount(item.content) : undefined
},
siteIcon(item: { siteIcon?: string }) {
if (item.siteIcon && !isBase64Image(item.siteIcon)) {
return createImageProxyUrl(item.siteIcon, 128, 128)
}
return item.siteIcon
},
async labels(
item: { id: string; labels?: Label[]; labelNames?: string[] | null },
_: unknown,
ctx: WithDataSourcesContext
) {
if (item.labels) return item.labels
if (item.labelNames && item.labelNames.length > 0) {
return findLabelsByLibraryItemId(item.id, ctx.uid)
}
return []
},
async recommendations(
item: {
id: string
recommendations?: Recommendation[]
recommenderNames?: string[] | null
},
_: unknown,
ctx: WithDataSourcesContext
) {
if (item.recommendations) return item.recommendations
if (item.recommenderNames && item.recommenderNames.length > 0) {
const recommendations = await findRecommendationsByLibraryItemId(
item.id,
ctx.uid
)
return recommendations.map(recommandationDataToRecommendation)
}
return []
},
},
Subscription: {
newsletterEmail(subscription: Subscription) {
return subscription.newsletterEmail?.address
},
icon(subscription: Subscription) {
return (
subscription.icon && createImageProxyUrl(subscription.icon, 128, 128)
)
},
},
...resultResolveTypeResolver('Login'),
...resultResolveTypeResolver('LogOut'),
...resultResolveTypeResolver('GoogleSignup'),
...resultResolveTypeResolver('UpdateUser'),
...resultResolveTypeResolver('UpdateUserProfile'),
...resultResolveTypeResolver('Article'),
// ...resultResolveTypeResolver('SharedArticle'),
...resultResolveTypeResolver('Articles'),
...resultResolveTypeResolver('User'),
...resultResolveTypeResolver('Users'),
...resultResolveTypeResolver('SaveArticleReadingProgress'),
// ...resultResolveTypeResolver('FeedArticles'),
...resultResolveTypeResolver('CreateArticle'),
...resultResolveTypeResolver('CreateHighlight'),
// ...resultResolveTypeResolver('CreateReaction'),
// ...resultResolveTypeResolver('DeleteReaction'),
...resultResolveTypeResolver('MergeHighlight'),
...resultResolveTypeResolver('UpdateHighlight'),
...resultResolveTypeResolver('DeleteHighlight'),
...resultResolveTypeResolver('UploadFileRequest'),
// ...resultResolveTypeResolver('SetShareArticle'),
// ...resultResolveTypeResolver('UpdateSharedComment'),
...resultResolveTypeResolver('SetBookmarkArticle'),
// ...resultResolveTypeResolver('SetFollow'),
// ...resultResolveTypeResolver('GetFollowers'),
// ...resultResolveTypeResolver('GetFollowing'),
...resultResolveTypeResolver('GetUserPersonalization'),
...resultResolveTypeResolver('SetUserPersonalization'),
...resultResolveTypeResolver('ArticleSavingRequest'),
...resultResolveTypeResolver('CreateArticleSavingRequest'),
// ...resultResolveTypeResolver('SetShareHighlight'),
...resultResolveTypeResolver('ArchiveLink'),
...resultResolveTypeResolver('CreateNewsletterEmail'),
...resultResolveTypeResolver('NewsletterEmails'),
...resultResolveTypeResolver('DeleteNewsletterEmail'),
...resultResolveTypeResolver('CreateReminder'),
...resultResolveTypeResolver('Reminder'),
...resultResolveTypeResolver('UpdateReminder'),
...resultResolveTypeResolver('DeleteReminder'),
...resultResolveTypeResolver('SetDeviceToken'),
...resultResolveTypeResolver('Save'),
...resultResolveTypeResolver('Labels'),
...resultResolveTypeResolver('CreateLabel'),
...resultResolveTypeResolver('DeleteLabel'),
...resultResolveTypeResolver('SetLabels'),
...resultResolveTypeResolver('GenerateApiKey'),
...resultResolveTypeResolver('Search'),
...resultResolveTypeResolver('Subscriptions'),
...resultResolveTypeResolver('Unsubscribe'),
...resultResolveTypeResolver('UpdateLabel'),
...resultResolveTypeResolver('SendInstallInstructions'),
...resultResolveTypeResolver('UpdatePage'),
...resultResolveTypeResolver('Subscribe'),
...resultResolveTypeResolver('AddPopularRead'),
...resultResolveTypeResolver('SetWebhook'),
...resultResolveTypeResolver('Webhooks'),
...resultResolveTypeResolver('DeleteWebhook'),
...resultResolveTypeResolver('Webhook'),
...resultResolveTypeResolver('ApiKeys'),
...resultResolveTypeResolver('RevokeApiKey'),
...resultResolveTypeResolver('DeleteAccount'),
...resultResolveTypeResolver('TypeaheadSearch'),
...resultResolveTypeResolver('UpdatesSince'),
...resultResolveTypeResolver('MoveLabel'),
...resultResolveTypeResolver('SetIntegration'),
...resultResolveTypeResolver('Integrations'),
...resultResolveTypeResolver('DeleteIntegration'),
...resultResolveTypeResolver('RecentSearches'),
...resultResolveTypeResolver('OptInFeature'),
...resultResolveTypeResolver('SetRule'),
...resultResolveTypeResolver('Rules'),
...resultResolveTypeResolver('DeviceTokens'),
...resultResolveTypeResolver('DeleteRule'),
...resultResolveTypeResolver('SaveFilter'),
...resultResolveTypeResolver('Filters'),
...resultResolveTypeResolver('DeleteFilter'),
...resultResolveTypeResolver('MoveFilter'),
...resultResolveTypeResolver('CreateGroup'),
...resultResolveTypeResolver('Groups'),
...resultResolveTypeResolver('Recommend'),
...resultResolveTypeResolver('JoinGroup'),
...resultResolveTypeResolver('RecommendHighlights'),
...resultResolveTypeResolver('LeaveGroup'),
...resultResolveTypeResolver('UploadImportFile'),
...resultResolveTypeResolver('RecentEmails'),
...resultResolveTypeResolver('MarkEmailAsItem'),
...resultResolveTypeResolver('BulkAction'),
...resultResolveTypeResolver('ImportFromIntegration'),
...resultResolveTypeResolver('SetFavoriteArticle'),
...resultResolveTypeResolver('UpdateSubscription'),
...resultResolveTypeResolver('UpdateEmail'),
...resultResolveTypeResolver('ScanFeeds'),
}

View File

@@ -0,0 +1,262 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-floating-promises */
import { DeepPartial } from 'typeorm'
import {
Highlight as HighlightData,
HighlightType,
} from '../../entity/highlight'
import { Label } from '../../entity/label'
import { env } from '../../env'
import {
CreateHighlightError,
CreateHighlightErrorCode,
CreateHighlightSuccess,
DeleteHighlightError,
DeleteHighlightErrorCode,
DeleteHighlightSuccess,
MergeHighlightError,
MergeHighlightErrorCode,
MergeHighlightSuccess,
MutationCreateHighlightArgs,
MutationDeleteHighlightArgs,
MutationMergeHighlightArgs,
MutationUpdateHighlightArgs,
UpdateHighlightError,
UpdateHighlightErrorCode,
UpdateHighlightSuccess,
} from '../../generated/graphql'
import { highlightRepository } from '../../repository/highlight'
import {
createHighlight,
deleteHighlightById,
mergeHighlights,
updateHighlight,
} from '../../services/highlights'
import { analytics } from '../../utils/analytics'
import { authorized, highlightDataToHighlight } from '../../utils/helpers'
export const createHighlightResolver = authorized<
CreateHighlightSuccess,
CreateHighlightError,
MutationCreateHighlightArgs
>(async (_, { input }, { log, pubsub, uid }) => {
try {
const newHighlight = await createHighlight(
{
...input,
user: { id: uid },
libraryItem: { id: input.articleId },
highlightType: input.type || HighlightType.Highlight,
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
highlightPositionPercent: input.highlightPositionPercent || 0,
},
input.articleId,
uid,
pubsub
)
analytics.track({
userId: uid,
event: 'highlight_created',
properties: {
libraryItemId: input.articleId,
env: env.server.apiEnv,
},
})
return { highlight: highlightDataToHighlight(newHighlight) }
} catch (err) {
log.error('Error creating highlight', err)
return {
errorCodes: [CreateHighlightErrorCode.Forbidden],
}
}
})
export const mergeHighlightResolver = authorized<
MergeHighlightSuccess,
MergeHighlightError,
MutationMergeHighlightArgs
>(async (_, { input }, { log, pubsub, uid }) => {
const { overlapHighlightIdList, ...newHighlightInput } = input
/* Compute merged annotation form the order of highlights appearing on page */
const mergedAnnotations: string[] = []
const mergedLabels: Label[] = []
const mergedColors: string[] = []
try {
const existingHighlights = await highlightRepository.findByLibraryItemId(
input.articleId,
uid
)
existingHighlights.forEach((highlight) => {
// filter out highlights that are in the overlap list
// and are of type highlight (not annotation or note)
if (
overlapHighlightIdList.includes(highlight.id) &&
highlight.highlightType === HighlightType.Highlight
) {
highlight.annotation && mergedAnnotations.push(highlight.annotation)
if (highlight.labels) {
// remove duplicates from labels by checking id
highlight.labels.forEach((label) => {
if (
!mergedLabels.find((mergedLabel) => mergedLabel.id === label.id)
) {
mergedLabels.push(label)
}
})
}
// collect colors of overlap highlights
highlight.color && mergedColors.push(highlight.color)
}
})
// use new color or the color of the last overlap highlight
const color =
newHighlightInput.color || mergedColors[mergedColors.length - 1]
const highlight: DeepPartial<HighlightData> = {
...newHighlightInput,
annotation:
mergedAnnotations.length > 0 ? mergedAnnotations.join('\n') : null,
labels: mergedLabels,
color,
user: { id: uid },
libraryItem: { id: input.articleId },
highlightPositionAnchorIndex: input.highlightPositionAnchorIndex || 0,
highlightPositionPercent: input.highlightPositionPercent || 0,
}
const newHighlight = await mergeHighlights(
overlapHighlightIdList,
highlight,
input.articleId,
uid,
pubsub
)
analytics.track({
userId: uid,
event: 'highlight_created',
properties: {
libraryItemId: input.articleId,
env: env.server.apiEnv,
},
})
return {
highlight: highlightDataToHighlight(newHighlight),
overlapHighlightIdList: input.overlapHighlightIdList,
}
} catch (e) {
log.error('Error merging highlight', e)
return {
errorCodes: [MergeHighlightErrorCode.Forbidden],
}
}
})
export const updateHighlightResolver = authorized<
UpdateHighlightSuccess,
UpdateHighlightError,
MutationUpdateHighlightArgs
>(async (_, { input }, { pubsub, uid, log }) => {
try {
const updatedHighlight = await updateHighlight(
input.highlightId,
{
annotation: input.annotation,
html: input.html,
quote: input.quote,
color: input.color,
},
uid,
pubsub
)
return { highlight: highlightDataToHighlight(updatedHighlight) }
} catch (error) {
log.error('updateHighlightResolver error', error)
return {
errorCodes: [UpdateHighlightErrorCode.Forbidden],
}
}
})
export const deleteHighlightResolver = authorized<
DeleteHighlightSuccess,
DeleteHighlightError,
MutationDeleteHighlightArgs
>(async (_, { highlightId }, { log }) => {
try {
const deletedHighlight = await deleteHighlightById(highlightId)
if (!deletedHighlight) {
return {
errorCodes: [DeleteHighlightErrorCode.NotFound],
}
}
return { highlight: highlightDataToHighlight(deletedHighlight) }
} catch (error) {
log.error('deleteHighlightResolver error', error)
return {
errorCodes: [DeleteHighlightErrorCode.Forbidden],
}
}
})
// export const setShareHighlightResolver = authorized<
// SetShareHighlightSuccess,
// SetShareHighlightError,
// MutationSetShareHighlightArgs
// >(async (_, { input: { id, share } }, { pubsub, claims, log }) => {
// const highlight = await getHighlightById(id)
// if (!highlight?.id) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
// if (highlight.userId !== claims.uid) {
// return {
// errorCodes: [SetShareHighlightErrorCode.Forbidden],
// }
// }
// const sharedAt = share ? new Date() : null
// log.info(`${share ? 'S' : 'Uns'}haring a highlight`, {
// highlight,
// labels: {
// source: 'resolver',
// resolver: 'setShareHighlightResolver',
// userId: highlight.userId,
// },
// })
// const updatedHighlight: HighlightData = {
// ...highlight,
// sharedAt,
// updatedAt: new Date(),
// }
// const updated = await updateHighlight(updatedHighlight, {
// pubsub,
// uid: claims.uid,
// refresh: true,
// })
// if (!updated) {
// return {
// errorCodes: [SetShareHighlightErrorCode.NotFound],
// }
// }
// return { highlight: highlightDataToHighlight(updatedHighlight) }
// })

View File

@@ -0,0 +1,90 @@
import { DateTime } from 'luxon'
import { v4 as uuidv4 } from 'uuid'
import { env } from '../../env'
import {
MutationUploadImportFileArgs,
UploadImportFileError,
UploadImportFileErrorCode,
UploadImportFileSuccess,
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
import { logger } from '../../utils/logger'
import {
countOfFilesWithPrefix,
generateUploadSignedUrl,
} from '../../utils/uploads'
const MAX_DAILY_UPLOADS = 1
const VALID_CONTENT_TYPES = ['text/csv', 'application/zip']
const extensionForContentType = (contentType: string) => {
switch (contentType) {
case 'text/csv':
return 'csv'
case 'application/zip':
return 'zip'
}
return '.unknown'
}
export const uploadImportFileResolver = authorized<
UploadImportFileSuccess,
UploadImportFileError,
MutationUploadImportFileArgs
>(async (_, { type, contentType }, { uid }) => {
if (!VALID_CONTENT_TYPES.includes(contentType)) {
return {
errorCodes: [UploadImportFileErrorCode.BadRequest],
}
}
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [UploadImportFileErrorCode.Unauthorized],
}
}
analytics.track({
userId: uid,
event: 'upload_import_file',
properties: {
type,
env: env.server.apiEnv,
},
})
// path style: imports/<uid>/<date>/<type>-<uuid>
const dateStr = DateTime.now().toISODate()
const dirPath = `imports/${uid}/${dateStr}/`
const fileCount = await countOfFilesWithPrefix(dirPath)
if (fileCount >= MAX_DAILY_UPLOADS) {
return {
errorCodes: [UploadImportFileErrorCode.UploadDailyLimitExceeded],
}
}
try {
const fileUuid = uuidv4()
const ext = extensionForContentType(contentType)
const fullPath = `${dirPath}${type}-${fileUuid}.${ext}`
const uploadSignedUrl = await generateUploadSignedUrl(fullPath, contentType)
return {
uploadSignedUrl,
}
} catch (error) {
logger.error('Error creating uploadSignedUrl', {
error,
type,
contentType,
})
return {
errorCodes: [UploadImportFileErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,26 @@
export * from './user'
export * from './article'
// export * from './user_friends'
// export * from './user_feed_article'
export * from './user_personalization'
export * from './article_saving_request'
export * from './upload_files'
export * from './highlight'
// export * from './reaction'
export * from './report'
export * from './links'
export * from './newsletters'
export * from './save'
export * from './send_install_instructions'
// export * from './reminders'
export * from './user_device_tokens'
export * from './labels'
export * from './subscriptions'
export * from './update'
export * from './popular_reads'
export * from './webhooks'
export * from './api_key'
export * from './integrations'
export * from './rules'
export * from './filters'
export * from './recommendations'

View File

@@ -0,0 +1,269 @@
import { DeepPartial } from 'typeorm'
import {
ImportItemState,
Integration,
IntegrationType,
} from '../../entity/integration'
import { env } from '../../env'
import {
DeleteIntegrationError,
DeleteIntegrationErrorCode,
DeleteIntegrationSuccess,
ImportFromIntegrationError,
ImportFromIntegrationErrorCode,
ImportFromIntegrationSuccess,
IntegrationsError,
IntegrationsErrorCode,
IntegrationsSuccess,
MutationDeleteIntegrationArgs,
MutationImportFromIntegrationArgs,
MutationSetIntegrationArgs,
SetIntegrationError,
SetIntegrationErrorCode,
SetIntegrationSuccess,
} from '../../generated/graphql'
import { createIntegrationToken } from '../../routers/auth/jwt_helpers'
import {
findIntegration,
findIntegrations,
getIntegrationClient,
removeIntegration,
saveIntegration,
updateIntegration,
} from '../../services/integrations'
import { analytics } from '../../utils/analytics'
import {
deleteTask,
enqueueExportToIntegration,
enqueueImportFromIntegration,
} from '../../utils/createTask'
import { authorized } from '../../utils/helpers'
export const setIntegrationResolver = authorized<
SetIntegrationSuccess,
SetIntegrationError,
MutationSetIntegrationArgs
>(async (_, { input }, { uid, log }) => {
try {
const integrationToSave: DeepPartial<Integration> = {
...input,
user: { id: uid },
id: input.id || undefined,
type: input.type || IntegrationType.Export,
syncedAt: input.syncedAt ? new Date(input.syncedAt) : undefined,
importItemState:
input.type === IntegrationType.Import
? input.importItemState || ImportItemState.Unarchived // default to unarchived
: undefined,
}
if (input.id) {
// Update
const existingIntegration = await findIntegration({ id: input.id }, uid)
if (!existingIntegration) {
return {
errorCodes: [SetIntegrationErrorCode.NotFound],
}
}
integrationToSave.id = existingIntegration.id
integrationToSave.taskName = existingIntegration.taskName
} else {
// Create
const integrationService = getIntegrationClient(input.name)
// authorize and get access token
const token = await integrationService.accessToken(input.token)
if (!token) {
return {
errorCodes: [SetIntegrationErrorCode.InvalidToken],
}
}
integrationToSave.token = token
}
// save integration
const integration = await saveIntegration(integrationToSave, uid)
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 enqueueExportToIntegration(
integration.id,
integration.name,
0,
authToken
)
log.info('enqueued task', taskName)
// update task name in integration
await updateIntegration(integration.id, { taskName }, uid)
integration.taskName = taskName
} else if (integrationToSave.taskName) {
// delete the task if disable integration and task exists
const result = await deleteTask(integrationToSave.taskName)
if (result) {
log.info('task deleted', integrationToSave.taskName)
}
// update task name in integration
await updateIntegration(
integration.id,
{
taskName: null,
},
uid
)
integration.taskName = null
}
analytics.track({
userId: uid,
event: 'integration_set',
properties: {
id: integrationToSave.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetIntegrationErrorCode.BadRequest],
}
}
})
export const integrationsResolver = authorized<
IntegrationsSuccess,
IntegrationsError
>(async (_, __, { uid, log }) => {
try {
const integrations = await findIntegrations(uid)
return {
integrations,
}
} catch (error) {
log.error(error)
return {
errorCodes: [IntegrationsErrorCode.BadRequest],
}
}
})
export const deleteIntegrationResolver = authorized<
DeleteIntegrationSuccess,
DeleteIntegrationError,
MutationDeleteIntegrationArgs
>(async (_, { id }, { claims: { uid }, log }) => {
log.info('deleteIntegrationResolver')
try {
const integration = await findIntegration({ id }, uid)
if (!integration) {
return {
errorCodes: [DeleteIntegrationErrorCode.NotFound],
}
}
if (integration.taskName) {
// delete the task if task exists
await deleteTask(integration.taskName)
log.info('task deleted', integration.taskName)
}
const deletedIntegration = await removeIntegration(integration, uid)
deletedIntegration.id = id
analytics.track({
userId: uid,
event: 'integration_delete',
properties: {
integrationId: deletedIntegration.id,
env: env.server.apiEnv,
},
})
return {
integration,
}
} catch (error) {
log.error(error)
return {
errorCodes: [DeleteIntegrationErrorCode.BadRequest],
}
}
})
export const importFromIntegrationResolver = authorized<
ImportFromIntegrationSuccess,
ImportFromIntegrationError,
MutationImportFromIntegrationArgs
>(async (_, { integrationId }, { claims: { uid }, log }) => {
try {
const integration = await findIntegration({ id: integrationId }, uid)
if (!integration) {
return {
errorCodes: [ImportFromIntegrationErrorCode.Unauthorized],
}
}
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,
integration.importItemState || ImportItemState.Unarchived
)
// update task name in integration
await updateIntegration(integration.id, { taskName }, uid)
analytics.track({
userId: uid,
event: 'integration_import',
properties: {
integrationId,
},
})
return {
success: true,
}
} catch (error) {
log.error(error)
return {
errorCodes: [ImportFromIntegrationErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,398 @@
import { Between } from 'typeorm'
import { isLabelSource, LabelSource } from '../../entity/entity_label'
import { Label } from '../../entity/label'
import { env } from '../../env'
import {
CreateLabelError,
CreateLabelErrorCode,
CreateLabelSuccess,
DeleteLabelError,
DeleteLabelErrorCode,
DeleteLabelSuccess,
LabelsError,
LabelsErrorCode,
LabelsSuccess,
MoveLabelError,
MoveLabelErrorCode,
MoveLabelSuccess,
MutationCreateLabelArgs,
MutationDeleteLabelArgs,
MutationMoveLabelArgs,
MutationSetLabelsArgs,
MutationSetLabelsForHighlightArgs,
MutationUpdateLabelArgs,
SetLabelsError,
SetLabelsErrorCode,
SetLabelsSuccess,
UpdateLabelError,
UpdateLabelErrorCode,
UpdateLabelSuccess,
} from '../../generated/graphql'
import { labelRepository } from '../../repository/label'
import { userRepository } from '../../repository/user'
import {
findOrCreateLabels,
saveLabelsInHighlight,
saveLabelsInLibraryItem,
updateLabel,
} from '../../services/labels'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const labelsResolver = authorized<LabelsSuccess, LabelsError>(
async (_obj, _params, { authTrx, log, uid }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [LabelsErrorCode.Unauthorized],
}
}
const labels = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).find({
where: {
user: { id: uid },
},
order: {
name: 'ASC',
},
})
})
analytics.track({
userId: uid,
event: 'labels',
properties: {
env: env.server.apiEnv,
$set: {
email: user.email,
username: user.profile.username,
},
},
})
return {
labels,
}
} catch (error) {
log.error('labelsResolver', error)
return {
errorCodes: [LabelsErrorCode.BadRequest],
}
}
}
)
export const createLabelResolver = authorized<
CreateLabelSuccess,
CreateLabelError,
MutationCreateLabelArgs
>(async (_, { input }, { authTrx, log, uid }) => {
try {
const label = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).createLabel(input, uid)
})
analytics.track({
userId: uid,
event: 'label_created',
properties: {
...input,
env: env.server.apiEnv,
},
})
return {
label,
}
} catch (error) {
log.error('createLabelResolver', error)
return {
errorCodes: [CreateLabelErrorCode.BadRequest],
}
}
})
export const deleteLabelResolver = authorized<
DeleteLabelSuccess,
DeleteLabelError,
MutationDeleteLabelArgs
>(async (_, { id: labelId }, { authTrx, log, uid }) => {
try {
const deleteResult = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).deleteById(labelId)
})
if (!deleteResult.affected) {
return {
errorCodes: [DeleteLabelErrorCode.NotFound],
}
}
analytics.track({
userId: uid,
event: 'label_deleted',
properties: {
labelId,
env: env.server.apiEnv,
},
})
return {
label: {
id: labelId,
name: '',
color: '',
},
}
} catch (error) {
log.error('error deleting label', error)
return {
errorCodes: [DeleteLabelErrorCode.BadRequest],
}
}
})
export const setLabelsResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsArgs
>(
async (
_,
{ input: { pageId, labelIds, labels, source } },
{ uid, log, authTrx, pubsub }
) => {
if (!labelIds && !labels) {
log.error('labelIds or labels must be provided')
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
let labelSource: LabelSource | undefined
// check if source is valid
if (source) {
if (!isLabelSource(source)) {
log.error('invalid source', source)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
labelSource = source
}
try {
let labelsSet: Label[] = []
if (labels && labels.length > 0) {
// for new clients that send label names
// create labels if they don't exist
labelsSet = await findOrCreateLabels(labels, uid)
} else if (labelIds && labelIds.length > 0) {
// for old clients that send labelIds
labelsSet = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findLabelsById(labelIds)
})
if (labelsSet.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
}
// save labels in the library item
await saveLabelsInLibraryItem(labelsSet, pageId, uid, labelSource, pubsub)
analytics.track({
userId: uid,
event: 'labels_set',
properties: {
pageId,
labelIds,
env: env.server.apiEnv,
},
})
return {
labels: labelsSet,
}
} catch (error) {
log.error('setLabelsResolver error', error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
}
)
export const updateLabelResolver = authorized<
UpdateLabelSuccess,
UpdateLabelError,
MutationUpdateLabelArgs
>(async (_, { input: { name, color, description, labelId } }, { uid, log }) => {
try {
const label = await updateLabel(labelId, { name, color, description }, uid)
return { label }
} catch (error) {
log.error('error updating label', error)
return {
errorCodes: [UpdateLabelErrorCode.BadRequest],
}
}
})
export const setLabelsForHighlightResolver = authorized<
SetLabelsSuccess,
SetLabelsError,
MutationSetLabelsForHighlightArgs
>(async (_, { input }, { uid, log, pubsub, authTrx }) => {
const { highlightId, labelIds, labels } = input
if (!labelIds && !labels) {
log.info('labelIds or labels must be provided')
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
try {
let labelsSet: Label[] = []
if (labels && labels.length > 0) {
// for new clients that send label names
// create labels if they don't exist
labelsSet = await findOrCreateLabels(labels, uid)
} else if (labelIds && labelIds.length > 0) {
// for old clients that send labelIds
labelsSet = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findLabelsById(labelIds)
})
if (labelsSet.length !== labelIds.length) {
return {
errorCodes: [SetLabelsErrorCode.NotFound],
}
}
}
// save labels in the library item
await saveLabelsInHighlight(labelsSet, input.highlightId, uid, pubsub)
analytics.track({
userId: uid,
event: 'labels_set_for_highlight',
properties: {
highlightId,
labelIds,
env: env.server.apiEnv,
},
})
return {
labels: labelsSet,
}
} catch (error) {
log.error('setLabelsForHighlightResolver error', error)
return {
errorCodes: [SetLabelsErrorCode.BadRequest],
}
}
})
export const moveLabelResolver = authorized<
MoveLabelSuccess,
MoveLabelError,
MutationMoveLabelArgs
>(async (_, { input }, { authTrx, log, uid }) => {
const { labelId, afterLabelId } = input
try {
const label = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findById(labelId)
})
if (!label) {
return {
errorCodes: [MoveLabelErrorCode.NotFound],
}
}
if (label.id === afterLabelId) {
// nothing to do
return { label }
}
const oldPosition = label.position
// if afterLabelId is not provided, move to the top
let newPosition = 1
if (afterLabelId) {
const afterLabel = await authTrx(async (tx) => {
return tx.withRepository(labelRepository).findById(afterLabelId)
})
if (!afterLabel) {
return {
errorCodes: [MoveLabelErrorCode.NotFound],
}
}
newPosition = afterLabel.position
}
const moveUp = newPosition < oldPosition
// move label to the new position
const updated = await authTrx(async (tx) => {
const labelRepo = tx.withRepository(labelRepository)
// update the position of the other labels
const updated = await labelRepo.update(
{
position: Between(
Math.min(newPosition, oldPosition),
Math.max(newPosition, oldPosition)
),
},
{
position: () => `position + ${moveUp ? 1 : -1}`,
}
)
if (!updated.affected) {
return null
}
// update the position of the label
return labelRepo.save({
id: labelId,
position: newPosition,
})
})
if (!updated) {
return {
errorCodes: [MoveLabelErrorCode.BadRequest],
}
}
analytics.track({
userId: uid,
event: 'label_moved',
properties: {
labelId,
afterLabelId,
env: env.server.apiEnv,
},
})
return {
label: updated,
}
} catch (error) {
log.error('error moving label', error)
return {
errorCodes: [MoveLabelErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,96 @@
import { LibraryItemState } from '../../entity/library_item'
import { env } from '../../env'
import {
ArchiveLinkError,
ArchiveLinkErrorCode,
ArchiveLinkSuccess,
MutationSetLinkArchivedArgs,
} from '../../generated/graphql'
import { updateLibraryItem } from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
// export const updateLinkShareInfoResolver = authorized<
// UpdateLinkShareInfoSuccess,
// UpdateLinkShareInfoError,
// MutationUpdateLinkShareInfoArgs
// >(async (_obj, args, { models, claims, authTrx, log }) => {
// const { title, description } = args.input
// log.info('updateLinkShareInfoResolver', args.input.linkId, title, description)
// // TEMP: because the old API uses articles instead of Links, we are actually
// // getting an article ID here and need to map it to a link ID. When the API
// // is updated to use Links instead of Articles this will be removed.
// const link = await authTrx((tx) =>
// models.userArticle.getByArticleId(claims.uid, args.input.linkId, tx)
// )
// if (!link?.id) {
// return {
// __typename: 'UpdateLinkShareInfoError',
// errorCodes: [UpdateLinkShareInfoErrorCode.Unauthorized],
// }
// }
// const result = await authTrx((tx) =>
// createOrUpdateLinkShareInfo(tx, link.id, title, description)
// )
// if (!result) {
// return {
// __typename: 'UpdateLinkShareInfoError',
// errorCodes: [UpdateLinkShareInfoErrorCode.BadRequest],
// }
// }
// return {
// __typename: 'UpdateLinkShareInfoSuccess',
// message: 'Updated Share Information',
// }
// })
export const setLinkArchivedResolver = authorized<
ArchiveLinkSuccess,
ArchiveLinkError,
MutationSetLinkArchivedArgs
>(async (_obj, args, { uid }) => {
let state = LibraryItemState.Archived
let archivedAt: Date | null = new Date()
let event = 'link_archived'
const isUnarchive = !args.input.archived
if (isUnarchive) {
state = LibraryItemState.Succeeded
archivedAt = null
event = 'link_unarchived'
}
analytics.track({
userId: uid,
event,
properties: {
env: env.server.apiEnv,
},
})
try {
await updateLibraryItem(
args.input.linkId,
{
state,
archivedAt,
},
uid
)
} catch (e) {
return {
message: 'An error occurred',
errorCodes: [ArchiveLinkErrorCode.BadRequest],
}
}
return {
linkId: args.input.linkId,
message: event,
}
})

View File

@@ -0,0 +1,130 @@
import { NewsletterEmail } from '../../entity/newsletter_email'
import { env } from '../../env'
import {
CreateNewsletterEmailError,
CreateNewsletterEmailErrorCode,
CreateNewsletterEmailSuccess,
DeleteNewsletterEmailError,
DeleteNewsletterEmailErrorCode,
DeleteNewsletterEmailSuccess,
MutationDeleteNewsletterEmailArgs,
NewsletterEmailsError,
NewsletterEmailsErrorCode,
NewsletterEmailsSuccess,
} from '../../generated/graphql'
import { getRepository } from '../../repository'
import {
createNewsletterEmail,
deleteNewsletterEmail,
getNewsletterEmails,
} from '../../services/newsletters'
import { unsubscribeAll } from '../../services/subscriptions'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const createNewsletterEmailResolver = authorized<
CreateNewsletterEmailSuccess,
CreateNewsletterEmailError
>(async (_parent, _args, { claims, log }) => {
log.info('createNewsletterEmailResolver')
analytics.track({
userId: claims.uid,
event: 'newsletter_email_address_created',
properties: {
env: env.server.apiEnv,
},
})
try {
const newsletterEmail = await createNewsletterEmail(claims.uid)
return {
newsletterEmail: {
...newsletterEmail,
subscriptionCount: 0,
},
}
} catch (e) {
log.info(e)
return {
errorCodes: [CreateNewsletterEmailErrorCode.BadRequest],
}
}
})
export const newsletterEmailsResolver = authorized<
NewsletterEmailsSuccess,
NewsletterEmailsError
>(async (_parent, _args, { uid, log }) => {
try {
const newsletterEmails = await getNewsletterEmails(uid)
return {
newsletterEmails: newsletterEmails.map((newsletterEmail) => ({
...newsletterEmail,
subscriptionCount: newsletterEmail.subscriptions.length,
})),
}
} catch (e) {
log.info(e)
return {
errorCodes: [NewsletterEmailsErrorCode.BadRequest],
}
}
})
export const deleteNewsletterEmailResolver = authorized<
DeleteNewsletterEmailSuccess,
DeleteNewsletterEmailError,
MutationDeleteNewsletterEmailArgs
>(async (_parent, args, { uid, log }) => {
analytics.track({
userId: uid,
event: 'newsletter_email_address_deleted',
properties: {
env: env.server.apiEnv,
},
})
try {
const newsletterEmail = await getRepository(NewsletterEmail).findOne({
where: {
id: args.newsletterEmailId,
user: { id: uid },
},
relations: ['user', 'subscriptions'],
})
if (!newsletterEmail) {
return {
errorCodes: [DeleteNewsletterEmailErrorCode.NotFound],
}
}
// unsubscribe all before deleting
await unsubscribeAll(newsletterEmail)
const deleted = await deleteNewsletterEmail(args.newsletterEmailId)
if (deleted) {
return {
newsletterEmail: {
...newsletterEmail,
subscriptionCount: newsletterEmail.subscriptions.length,
},
}
} else {
// when user tries to delete other's newsletters emails or email already deleted
return {
errorCodes: [DeleteNewsletterEmailErrorCode.NotFound],
}
}
} catch (error) {
log.error('deleteNewsletterEmailResolver', error)
return {
errorCodes: [DeleteNewsletterEmailErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,22 @@
import {
AddPopularReadError,
AddPopularReadErrorCode,
AddPopularReadSuccess,
MutationAddPopularReadArgs,
} from '../../generated/graphql'
import { addPopularRead } from '../../services/popular_reads'
import { authorized } from '../../utils/helpers'
export const addPopularReadResolver = authorized<
AddPopularReadSuccess,
AddPopularReadError,
MutationAddPopularReadArgs
>(async (_, { name }, { uid }) => {
const items = await addPopularRead(uid, name)
if (items.length === 0) {
return { errorCodes: [AddPopularReadErrorCode.NotFound] }
}
return {
pageId: items[0].id,
}
})

View File

@@ -0,0 +1,132 @@
import { Merge } from '../../util'
import { CreateReactionSuccess, Reaction } from './../../generated/graphql'
export type PartialReaction = Omit<Reaction, 'user'>
export type PartialCreateReactionSuccess = Merge<
CreateReactionSuccess,
{ reaction: PartialReaction }
>
// export const createReactionResolver = authorized<
// PartialCreateReactionSuccess,
// CreateReactionError,
// MutationCreateReactionArgs
// >(async (_, { input }, { models, claims, log, authTrx }) => {
// const { userArticleId, highlightId } = input
// if ((!userArticleId && !highlightId) || (userArticleId && highlightId)) {
// // One reaction target is required
// // Highlight replies hasn't supported yet
// return {
// errorCodes: [CreateReactionErrorCode.BadTarget],
// }
// }
// if (input.code && input.code.length > 50) {
// return {
// errorCodes: [CreateReactionErrorCode.BadCode],
// }
// }
// if (userArticleId) {
// if (!(await models.userArticle.get(userArticleId))) {
// return {
// errorCodes: [CreateReactionErrorCode.BadTarget],
// }
// }
// } else if (highlightId) {
// if (!(await models.highlight.get(highlightId))) {
// return {
// errorCodes: [CreateReactionErrorCode.BadTarget],
// }
// }
// }
// try {
// const previousReaction = userArticleId
// ? await models.reaction.getByUserAndParam(claims.uid, { userArticleId })
// : await models.reaction.getByUserAndParam(claims.uid, { highlightId })
// let reaction
// if (!previousReaction) {
// reaction = await authTrx((tx) =>
// models.reaction.create({ ...input, userId: claims.uid }, tx)
// )
// } else {
// reaction = await authTrx((tx) =>
// models.reaction.update(
// previousReaction.id,
// {
// code: input.code,
// },
// tx
// )
// )
// }
// if (!reaction) {
// return {
// errorCodes: [CreateReactionErrorCode.NotFound],
// }
// }
// log.info(`${previousReaction ? 'Updating' : 'Creating'} a new reaction`, {
// reaction,
// labels: {
// source: 'resolver',
// resolver: 'createReactionResolver',
// uid: claims.uid,
// },
// })
// return {
// reaction: reaction as PartialReaction,
// }
// } catch (err) {
// log.info(err)
// return {
// errorCodes: [CreateReactionErrorCode.NotFound],
// }
// }
// })
// export const deleteReactionResolver = authorized<
// PartialCreateReactionSuccess,
// DeleteReactionError,
// MutationDeleteReactionArgs
// >(async (_, { id }, { authTrx, models, claims, log }) => {
// const reaction = await models.reaction.get(id)
// if (!reaction?.id) {
// return {
// errorCodes: [DeleteReactionErrorCode.NotFound],
// }
// }
// if (reaction.userId !== claims.uid) {
// return {
// errorCodes: [DeleteReactionErrorCode.Forbidden],
// }
// }
// const deleted = await authTrx((tx) => models.reaction.delete(id, tx))
// if ('error' in deleted) {
// return {
// errorCodes: [DeleteReactionErrorCode.NotFound],
// }
// }
// log.info('Deleting a highlight', {
// deleted,
// labels: {
// source: 'resolver',
// resolver: 'deleteHighlightResolver',
// uid: claims.uid,
// },
// })
// return {
// reaction: reaction as PartialReaction,
// }
// })

View File

@@ -0,0 +1,135 @@
import { ILike } from 'typeorm'
import { NewsletterEmail } from '../../entity/newsletter_email'
import { ReceivedEmail } from '../../entity/received_email'
import { env } from '../../env'
import {
MarkEmailAsItemError,
MarkEmailAsItemErrorCode,
MarkEmailAsItemSuccess,
MutationMarkEmailAsItemArgs,
RecentEmailsError,
RecentEmailsErrorCode,
RecentEmailsSuccess,
} from '../../generated/graphql'
import { updateReceivedEmail } from '../../services/received_emails'
import { saveNewsletter } from '../../services/save_newsletter_email'
import { authorized } from '../../utils/helpers'
import { generateUniqueUrl, parseEmailAddress } from '../../utils/parser'
import { sendEmail } from '../../utils/sendEmail'
export const recentEmailsResolver = authorized<
RecentEmailsSuccess,
RecentEmailsError
>(async (_, __, { authTrx, log, uid }) => {
try {
const recentEmails = await authTrx((t) =>
t.getRepository(ReceivedEmail).find({
where: {
user: { id: uid },
},
order: { createdAt: 'DESC' },
take: 20,
})
)
return {
recentEmails,
}
} catch (error) {
log.error('Error getting recent emails', error)
return {
errorCodes: [RecentEmailsErrorCode.BadRequest],
}
}
})
export const markEmailAsItemResolver = authorized<
MarkEmailAsItemSuccess,
MarkEmailAsItemError,
MutationMarkEmailAsItemArgs
>(async (_, { recentEmailId }, { authTrx, uid, log }) => {
try {
const recentEmail = await authTrx((t) =>
t.getRepository(ReceivedEmail).findOneBy({
id: recentEmailId,
user: { id: uid },
type: 'non-article',
})
)
if (!recentEmail) {
log.info('no recent email', recentEmailId)
return {
errorCodes: [MarkEmailAsItemErrorCode.Unauthorized],
}
}
const newsletterEmail = await authTrx((t) =>
t.getRepository(NewsletterEmail).findOne({
where: {
user: { id: uid },
address: ILike(recentEmail.to),
},
relations: ['user'],
})
)
if (!newsletterEmail) {
log.info('no newsletter email for', {
id: recentEmail.id,
to: recentEmail.to,
from: recentEmail.from,
})
return {
errorCodes: [MarkEmailAsItemErrorCode.NotFound],
}
}
const success = await saveNewsletter(
{
from: recentEmail.from,
email: recentEmail.to,
title: recentEmail.subject,
content: recentEmail.html,
url: generateUniqueUrl(),
author: parseEmailAddress(recentEmail.from).name,
receivedEmailId: recentEmail.id,
},
newsletterEmail
)
if (!success) {
log.info('newsletter not created', recentEmail.id)
return {
errorCodes: [MarkEmailAsItemErrorCode.BadRequest],
}
}
// update received email type
await updateReceivedEmail(recentEmail.id, 'article', uid)
const text = `A recent email marked as a library item
by: ${uid}
from: ${recentEmail.from}
subject: ${recentEmail.subject}`
// email us to let us know that an email failed to parse as an article
await sendEmail({
to: env.sender.feedback,
subject: 'A recent email marked as a library item',
text,
from: env.sender.message,
})
return {
success,
}
} catch (error) {
log.error('Error marking email as item', error)
return {
errorCodes: [MarkEmailAsItemErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,16 @@
import {
RecentSearchesError,
RecentSearchesSuccess,
} from '../../generated/graphql'
import { getRecentSearches } from '../../services/search_history'
import { authorized } from '../../utils/helpers'
export const recentSearchesResolver = authorized<
RecentSearchesSuccess,
RecentSearchesError
>(async (_obj, _params) => {
const searches = await getRecentSearches()
return {
searches,
}
})

View File

@@ -0,0 +1,339 @@
import { In } from 'typeorm'
import { Group } from '../../entity/groups/group'
import { env } from '../../env'
import {
CreateGroupError,
CreateGroupErrorCode,
CreateGroupSuccess,
GroupsError,
GroupsErrorCode,
GroupsSuccess,
JoinGroupError,
JoinGroupErrorCode,
JoinGroupSuccess,
LeaveGroupError,
LeaveGroupErrorCode,
LeaveGroupSuccess,
MutationCreateGroupArgs,
MutationJoinGroupArgs,
MutationLeaveGroupArgs,
MutationRecommendArgs,
MutationRecommendHighlightsArgs,
RecommendError,
RecommendErrorCode,
RecommendHighlightsError,
RecommendHighlightsErrorCode,
RecommendHighlightsSuccess,
RecommendSuccess,
} from '../../generated/graphql'
import { getRepository } from '../../repository'
import { userRepository } from '../../repository/user'
import {
createGroup,
createLabelAndRuleForGroup,
getGroupsWhereUserCanPost,
getInviteUrl,
getRecommendationGroups,
joinGroup,
leaveGroup,
} from '../../services/groups'
import { findLibraryItemById } from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import { enqueueRecommendation } from '../../utils/createTask'
import { authorized, userDataToUser } from '../../utils/helpers'
export const createGroupResolver = authorized<
CreateGroupSuccess,
CreateGroupError,
MutationCreateGroupArgs
>(async (_, { input }, { uid, log }) => {
try {
const userData = await userRepository.findById(uid)
if (!userData) {
return {
errorCodes: [CreateGroupErrorCode.Unauthorized],
}
}
const [group, invite] = await createGroup({
admin: userData,
name: input.name,
maxMembers: input.maxMembers,
expiresInDays: input.expiresInDays,
description: input.description,
topics: input.topics,
onlyAdminCanPost: input.onlyAdminCanPost,
onlyAdminCanSeeMembers: input.onlyAdminCanSeeMembers,
})
analytics.track({
userId: uid,
event: 'group_created',
properties: {
group_id: group.id,
group_name: group.name,
group_invite_code: invite.code,
},
})
await createLabelAndRuleForGroup(uid, group.name)
const inviteUrl = getInviteUrl(invite)
const user = userDataToUser(userData)
return {
group: {
...group,
inviteUrl,
admins: [user],
members: [user],
canSeeMembers: true,
canPost: true,
description: group.description,
topics: group.topics?.split(','),
},
}
} catch (error) {
log.error('Error creating group', error)
return {
errorCodes: [CreateGroupErrorCode.BadRequest],
}
}
})
export const groupsResolver = authorized<GroupsSuccess, GroupsError>(
async (_, __, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [GroupsErrorCode.Unauthorized],
}
}
const groups = await getRecommendationGroups(user)
return {
groups,
}
} catch (error) {
log.error('Error getting groups', {
error,
labels: {
source: 'resolver',
resolver: 'groupsResolver',
uid,
},
})
return {
errorCodes: [GroupsErrorCode.BadRequest],
}
}
}
)
export const recommendResolver = authorized<
RecommendSuccess,
RecommendError,
MutationRecommendArgs
>(async (_, { input }, { uid, log, signToken }) => {
try {
const item = await findLibraryItemById(input.pageId, uid)
if (!item) {
return {
errorCodes: [RecommendErrorCode.NotFound],
}
}
// find groups where id is in the groupIds and the user is a member of the group and the user is allowed to post
const groups = await getGroupsWhereUserCanPost(uid, input.groupIds)
if (groups.length === 0) {
return {
errorCodes: [RecommendErrorCode.NotFound],
}
}
// only recommend highlights created by the user
const recommendedHighlightIds = input.recommendedWithHighlights
? item.highlights?.filter((h) => h.user.id === uid)?.map((h) => h.id)
: undefined
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
const taskNames = await Promise.all(
groups
.map((group) =>
group.members.map((member) =>
enqueueRecommendation(
member.user.id,
item.id,
{
group: { id: group.id },
note: input.note,
recommender: { id: uid },
createdAt: new Date(),
libraryItem: { id: item.id },
},
auth,
recommendedHighlightIds
)
)
)
.flat()
)
log.info('taskNames', taskNames)
return {
success: true,
}
} catch (error) {
log.error('Error recommending', error)
return {
errorCodes: [RecommendErrorCode.BadRequest],
}
}
})
export const joinGroupResolver = authorized<
JoinGroupSuccess,
JoinGroupError,
MutationJoinGroupArgs
>(async (_, { inviteCode }, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [JoinGroupErrorCode.Unauthorized],
}
}
const group = await joinGroup(user, inviteCode)
analytics.track({
userId: uid,
event: 'group_joined',
properties: {
group_id: group.id,
group_name: group.name,
},
})
await createLabelAndRuleForGroup(user.id, group.name)
return {
group,
}
} catch (error) {
log.error('Error joining group', error)
return {
errorCodes: [JoinGroupErrorCode.BadRequest],
}
}
})
export const recommendHighlightsResolver = authorized<
RecommendHighlightsSuccess,
RecommendHighlightsError,
MutationRecommendHighlightsArgs
>(async (_, { input }, { uid, log, signToken }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [RecommendHighlightsErrorCode.Unauthorized],
}
}
const groups = await getRepository(Group).find({
where: { id: In(input.groupIds) },
relations: ['members', 'members.user'],
})
if (groups.length === 0) {
return {
errorCodes: [RecommendHighlightsErrorCode.NotFound],
}
}
const item = await findLibraryItemById(input.pageId, uid)
if (!item) {
return {
errorCodes: [RecommendHighlightsErrorCode.NotFound],
}
}
const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 1 day
const auth = (await signToken({ uid, exp }, env.server.jwtSecret)) as string
await Promise.all(
groups
.map((group) =>
group.members
.filter((member) => member.user.id !== uid)
.map((member) =>
enqueueRecommendation(
member.user.id,
item.id,
{
id: group.id,
note: input.note,
recommender: { id: uid },
createdAt: new Date(),
libraryItem: { id: item.id },
},
auth,
input.highlightIds
)
)
)
.flat()
)
return {
success: true,
}
} catch (error) {
log.error('Error recommending highlights', error)
return {
errorCodes: [RecommendHighlightsErrorCode.BadRequest],
}
}
})
export const leaveGroupResolver = authorized<
LeaveGroupSuccess,
LeaveGroupError,
MutationLeaveGroupArgs
>(async (_, { groupId }, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [LeaveGroupErrorCode.Unauthorized],
}
}
const success = await leaveGroup(user, groupId)
analytics.track({
userId: uid,
event: 'group_left',
properties: {
group_id: groupId,
},
})
return {
success,
}
} catch (error) {
log.error('Error leaving group', error)
return {
errorCodes: [LeaveGroupErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,331 @@
import { DateTime } from 'luxon'
const validScheduleTime = (str: string): Date | undefined => {
const scheduleTime = DateTime.fromISO(str, { setZone: true }).set({
minute: 0,
second: 0,
millisecond: 0,
})
if (scheduleTime <= DateTime.now()) {
return undefined
}
return scheduleTime.toJSDate()
}
// export const createReminderResolver = authorized<
// CreateReminderSuccess,
// CreateReminderError,
// MutationCreateReminderArgs
// >(async (_, { input }, { models, claims: { uid }, log }) => {
// log.info('createReminderResolver')
// const { clientRequestId, linkId, archiveUntil, sendNotification } = input
// const scheduledTime = validScheduleTime(input.remindAt)
// if (!scheduledTime) {
// log.error('Invalid scheduled time', input.remindAt)
// return {
// errorCodes: [CreateReminderErrorCode.BadRequest],
// }
// }
// const pageId = linkId || clientRequestId
// if (!pageId) {
// log.error('client request id or link id is required')
// return {
// errorCodes: [CreateReminderErrorCode.BadRequest],
// }
// }
// analytics.track({
// userId: uid,
// event: 'reminder_created',
// properties: {
// clientRequestId,
// remindAt: scheduledTime,
// archiveUntil,
// sendNotification,
// linkId,
// env: env.server.apiEnv,
// },
// })
// try {
// // saving from web
// const page = await getPageById(pageId)
// if (!page) {
// log.error('page not found', pageId)
// return {
// errorCodes: [CreateReminderErrorCode.NotFound],
// }
// }
// if (page.userId !== uid) {
// log.error('user not authorized', uid)
// return {
// errorCodes: [CreateReminderErrorCode.Unauthorized],
// }
// }
// if (archiveUntil) {
// await archivePage(uid, page)
// }
// const taskName = await groupReminders(scheduledTime, uid, models)
// log.info('scheduled task name', taskName)
// // insert reminder to db
// const reminder = await models.reminder.create({
// userId: uid,
// taskName: taskName,
// archiveUntil: archiveUntil,
// sendNotification: sendNotification,
// createdAt: new Date(),
// remindAt: scheduledTime,
// elasticPageId: pageId,
// })
// log.info('created reminder', reminder)
// return {
// reminder: {
// id: reminder.id,
// archiveUntil,
// sendNotification,
// remindAt: scheduledTime,
// },
// }
// } catch (e) {
// log.info('error creating reminder', e)
// return {
// errorCodes: [CreateReminderErrorCode.BadRequest],
// }
// }
// })
// // Attempts to find a link and archive it if it exists.
// // It is possible that the link has not been created
// // yet if it is still in the saving process. In that
// // case it will be archived when the link is created.
// const archivePage = async (uid: string, page: Page) => {
// try {
// await setLinkArchived(uid, page.id, true)
// } catch (e) {
// logger.info('error archiving link', e)
// }
// }
// export const reminderResolver = authorized<
// ReminderSuccess,
// ReminderError,
// QueryReminderArgs
// >(async (_, { linkId: pageId }, { models, claims: { uid }, log }) => {
// log.info('reminderResolver')
// analytics.track({
// userId: uid,
// event: 'reminder',
// properties: {
// linkId: pageId,
// env: env.server.apiEnv,
// },
// })
// try {
// // get page from articleId
// const page = await getPageById(pageId)
// if (!page) {
// return {
// errorCodes: [ReminderErrorCode.NotFound],
// }
// }
// if (page.userId !== uid) {
// return {
// errorCodes: [ReminderErrorCode.Unauthorized],
// }
// }
// const reminder = await models.reminder.getCreatedByParameters(uid, {
// elasticPageId: page.id,
// })
// if (!reminder) {
// log.error('reminder not found: pageId: ', pageId)
// return {
// errorCodes: [ReminderErrorCode.NotFound],
// }
// }
// return {
// reminder: {
// id: reminder.id,
// archiveUntil: reminder.archiveUntil || false,
// sendNotification: reminder.sendNotification || true,
// remindAt: reminder.remindAt,
// },
// }
// } catch (e) {
// log.error(e)
// return {
// errorCodes: [ReminderErrorCode.BadRequest],
// }
// }
// })
// export const updateReminderResolver = authorized<
// UpdateReminderSuccess,
// UpdateReminderError,
// MutationUpdateReminderArgs
// >(async (_, { input }, { models, claims: { uid }, log, authTrx }) => {
// log.info('updateReminderResolver')
// const { id, archiveUntil, sendNotification } = input
// const scheduledTime = validScheduleTime(input.remindAt)
// if (!scheduledTime) {
// log.error('Invalid scheduled time', input.remindAt)
// return {
// errorCodes: [UpdateReminderErrorCode.BadRequest],
// }
// }
// analytics.track({
// userId: uid,
// event: 'reminder_updated',
// properties: {
// id,
// remindAt: scheduledTime,
// archiveUntil,
// sendNotification,
// env: env.server.apiEnv,
// },
// })
// try {
// const reminder = await models.reminder.getCreated(id)
// if (!reminder) {
// log.error('reminder not found:', id)
// return {
// errorCodes: [UpdateReminderErrorCode.NotFound],
// }
// }
// if (reminder.userId !== uid) {
// return {
// errorCodes: [UpdateReminderErrorCode.Unauthorized],
// }
// }
// // delete old google cloud task
// if (reminder.taskName) {
// await deleteTask(reminder.taskName)
// }
// const taskName = await groupReminders(scheduledTime, uid, models)
// // update db
// await authTrx((tx) =>
// models.reminder.update(
// id,
// {
// taskName: taskName,
// archiveUntil,
// sendNotification,
// remindAt: scheduledTime,
// },
// tx
// )
// )
// return {
// reminder: {
// id: reminder.id,
// archiveUntil,
// sendNotification,
// remindAt: scheduledTime,
// },
// }
// } catch (e) {
// log.error(e)
// return {
// errorCodes: [UpdateReminderErrorCode.BadRequest],
// }
// }
// })
// export const deleteReminderResolver = authorized<
// DeleteReminderSuccess,
// DeleteReminderError,
// MutationDeleteReminderArgs
// >(async (_, { id }, { models, claims: { uid }, log, authTrx }) => {
// log.info('deleteReminderResolver')
// analytics.track({
// userId: uid,
// event: 'reminder_deleted',
// properties: {
// id: id,
// env: env.server.apiEnv,
// },
// })
// try {
// const reminder = await models.reminder.getCreated(id)
// if (!reminder) {
// log.error('reminder not found:', id)
// return {
// errorCodes: [DeleteReminderErrorCode.NotFound],
// }
// }
// if (reminder.userId !== uid) {
// return {
// errorCodes: [DeleteReminderErrorCode.Unauthorized],
// }
// }
// // update db
// await authTrx((tx) => models.reminder.delete(id, tx))
// return {
// reminder: {
// id: reminder.id,
// archiveUntil: reminder.archiveUntil || false,
// sendNotification: reminder.sendNotification || true,
// remindAt: reminder.remindAt,
// },
// }
// } catch (e) {
// log.error(e)
// return {
// errorCodes: [DeleteReminderErrorCode.BadRequest],
// }
// }
// })
// // check if there exists reminders for the same user at the same time
// // create a Google cloud task if no existing task and return task name
// const groupReminders = async (
// scheduledTime: Date,
// userId: string,
// models: DataModels
// ): Promise<string | undefined> => {
// const exists = await models.reminder.existByUserAndRemindAt(
// userId,
// scheduledTime
// )
// if (!exists) {
// return enqueueReminder(userId, scheduledTime.getTime())
// }
// return undefined
// }

View File

@@ -0,0 +1,93 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { WithDataSourcesContext } from '../types'
import {
MutationReportItemArgs,
ReportItemResult,
ReportType,
ResolverFn,
} from '../../generated/graphql'
import {
saveAbuseReport,
saveContentDisplayReport,
} from '../../services/reports'
import { analytics } from '../../utils/analytics'
import { env } from '../../env'
const SUCCESS_MESSAGE = `Your report has been submitted. Thank you.`
const FAILURE_MESSAGE =
'There was an error submitting your report. If this issue persists please use email or the feedback tool.'
const isAbuseReport = (types: ReportType[]): boolean => {
// If the list contains any types other than ReportType.Content
// it is an abuse report.
if (types.length == 1 && types[0] == ReportType.ContentDisplay) {
return false
}
return true
}
const isContentDisplayReport = (types: ReportType[]): boolean => {
if (types.length == 1 && types[0] == ReportType.ContentDisplay) {
return true
}
return false
}
export const reportItemResolver: ResolverFn<
ReportItemResult,
unknown,
WithDataSourcesContext,
MutationReportItemArgs
> = async (_obj, args, ctx) => {
const { sharedBy, reportTypes } = args.input
if (sharedBy && isAbuseReport(reportTypes)) {
analytics.track({
userId: sharedBy,
event: 'report_created',
properties: {
type: 'abuse',
env: env.server.apiEnv,
},
})
// SharedBy is nullable for some report types, but not for abuse reports
// So we force it
const uid = ctx.claims?.uid || ''
const value = { sharedBy: uid, reportedBy: uid, ...args.input }
const report = await saveAbuseReport(uid, value)
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
return {
message: message,
}
} else if (isContentDisplayReport(reportTypes)) {
// Content Display messages require a uid, since only users can see content
const uid = ctx.claims?.uid
if (!uid) {
return {
message: FAILURE_MESSAGE,
}
}
analytics.track({
userId: uid,
event: 'report_created',
properties: {
type: 'content',
url: args.input.itemUrl,
env: env.server.apiEnv,
},
})
const report = await saveContentDisplayReport(uid, args.input)
const message = report ? SUCCESS_MESSAGE : FAILURE_MESSAGE
return {
message: message,
}
}
return {
message: FAILURE_MESSAGE,
}
}

View File

@@ -0,0 +1,88 @@
import { Rule } from '../../entity/rule'
import {
DeleteRuleError,
DeleteRuleErrorCode,
DeleteRuleSuccess,
MutationDeleteRuleArgs,
MutationSetRuleArgs,
QueryRulesArgs,
RulesError,
RulesErrorCode,
RulesSuccess,
SetRuleError,
SetRuleErrorCode,
SetRuleSuccess,
} from '../../generated/graphql'
import { deleteRule } from '../../services/rules'
import { authorized } from '../../utils/helpers'
export const setRuleResolver = authorized<
SetRuleSuccess,
SetRuleError,
MutationSetRuleArgs
>(async (_, { input }, { authTrx, uid, log }) => {
try {
const rule = await authTrx((t) =>
t.getRepository(Rule).save({
...input,
id: input.id || undefined,
user: { id: uid },
})
)
return {
rule,
}
} catch (error) {
log.error('Error setting rules', error)
return {
errorCodes: [SetRuleErrorCode.BadRequest],
}
}
})
export const rulesResolver = authorized<
RulesSuccess,
RulesError,
QueryRulesArgs
>(async (_, { enabled }, { authTrx, log, uid }) => {
try {
const rules = await authTrx((t) =>
t.getRepository(Rule).findBy({
user: { id: uid },
enabled: enabled === null ? undefined : enabled,
})
)
return {
rules,
}
} catch (error) {
log.error('Error getting rules', error)
return {
errorCodes: [RulesErrorCode.BadRequest],
}
}
})
export const deleteRuleResolver = authorized<
DeleteRuleSuccess,
DeleteRuleError,
MutationDeleteRuleArgs
>(async (_, { id }, { uid, log }) => {
try {
const rule = await deleteRule(id, uid)
return {
rule,
}
} catch (error) {
log.error('Error deleting rule', error)
return {
errorCodes: [DeleteRuleErrorCode.NotFound],
}
}
})

View File

@@ -0,0 +1,87 @@
import { env } from '../../env'
import {
MutationSaveFileArgs,
MutationSavePageArgs,
MutationSaveUrlArgs,
SaveError,
SaveErrorCode,
SaveSuccess,
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { saveFile } from '../../services/save_file'
import { savePage } from '../../services/save_page'
import { saveUrl } from '../../services/save_url'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const savePageResolver = authorized<
SaveSuccess,
SaveError,
MutationSavePageArgs
>(async (_, { input }, { uid }) => {
analytics.track({
userId: uid,
event: 'link_saved',
properties: {
url: input.url,
method: 'page',
source: input.source,
env: env.server.apiEnv,
},
})
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [SaveErrorCode.Unauthorized] }
}
return savePage(input, user)
})
export const saveUrlResolver = authorized<
SaveSuccess,
SaveError,
MutationSaveUrlArgs
>(async (_, { input }, { uid }) => {
analytics.track({
userId: uid,
event: 'link_saved',
properties: {
url: input.url,
method: 'url',
source: input.source,
env: env.server.apiEnv,
},
})
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [SaveErrorCode.Unauthorized] }
}
return saveUrl(input, user)
})
export const saveFileResolver = authorized<
SaveSuccess,
SaveError,
MutationSaveFileArgs
>(async (_, { input }, { uid }) => {
analytics.track({
userId: uid,
event: 'link_saved',
properties: {
url: input.url,
method: 'file',
source: input.source,
env: env.server.apiEnv,
},
})
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [SaveErrorCode.Unauthorized] }
}
return saveFile(input, user)
})

View File

@@ -0,0 +1,43 @@
import { env } from '../../env'
import {
SendInstallInstructionsError,
SendInstallInstructionsErrorCode,
SendInstallInstructionsSuccess,
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { authorized } from '../../utils/helpers'
import { sendEmail } from '../../utils/sendEmail'
const INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID =
'd-c576bdc3b9a849dab250655ba14c7794'
export const sendInstallInstructionsResolver = authorized<
SendInstallInstructionsSuccess,
SendInstallInstructionsError
>(async (_parent, _args, { uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [SendInstallInstructionsErrorCode.Unauthorized] }
}
const sendInstallInstructions = await sendEmail({
from: env.sender.message,
templateId:
env.sendgrid.installationTemplateId ||
INSTALL_INSTRUCTIONS_EMAIL_TEMPLATE_ID,
to: user?.email,
})
return {
sent: sendInstallInstructions,
}
} catch (e) {
log.info(e)
return {
errorCodes: [SendInstallInstructionsErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,494 @@
import axios from 'axios'
import { parseHTML } from 'linkedom'
import { Brackets } from 'typeorm'
import { Subscription } from '../../entity/subscription'
import { env } from '../../env'
import {
FeedEdge,
FeedsError,
FeedsErrorCode,
FeedsSuccess,
MutationSubscribeArgs,
MutationUnsubscribeArgs,
MutationUpdateSubscriptionArgs,
QueryFeedsArgs,
QueryScanFeedsArgs,
QuerySubscriptionsArgs,
ScanFeedsError,
ScanFeedsErrorCode,
ScanFeedsSuccess,
SortBy,
SortOrder,
SubscribeError,
SubscribeErrorCode,
SubscribeSuccess,
SubscriptionsError,
SubscriptionsErrorCode,
SubscriptionsSuccess,
SubscriptionStatus,
SubscriptionType,
UnsubscribeError,
UnsubscribeErrorCode,
UnsubscribeSuccess,
UpdateSubscriptionError,
UpdateSubscriptionErrorCode,
UpdateSubscriptionSuccess,
} from '../../generated/graphql'
import { getRepository } from '../../repository'
import { feedRepository } from '../../repository/feed'
import { unsubscribe } from '../../services/subscriptions'
import { Merge } from '../../util'
import { analytics } from '../../utils/analytics'
import { enqueueRssFeedFetch } from '../../utils/createTask'
import {
authorized,
getAbsoluteUrl,
keysToCamelCase,
} from '../../utils/helpers'
import { parseFeed, parseOpml, RSS_PARSER_CONFIG } from '../../utils/parser'
type PartialSubscription = Omit<Subscription, 'newsletterEmail'>
export type SubscriptionsSuccessPartial = Merge<
SubscriptionsSuccess,
{ subscriptions: PartialSubscription[] }
>
export const subscriptionsResolver = authorized<
SubscriptionsSuccessPartial,
SubscriptionsError,
QuerySubscriptionsArgs
>(async (_obj, { sort, type }, { uid, log }) => {
try {
const sortBy =
sort?.by === SortBy.UpdatedTime ? 'lastFetchedAt' : 'createdAt'
const sortOrder = sort?.order === SortOrder.Ascending ? 'ASC' : 'DESC'
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({
user: { id: uid },
})
if (type && type == SubscriptionType.Newsletter) {
queryBuilder.andWhere({
type,
status: SubscriptionStatus.Active,
})
} else if (type && type == SubscriptionType.Rss) {
queryBuilder.andWhere({
type,
})
} else {
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where({
type: SubscriptionType.Newsletter,
status: SubscriptionStatus.Active,
}).orWhere({
type: SubscriptionType.Rss,
})
})
)
}
const subscriptions = await queryBuilder
.orderBy('subscription.status', 'ASC')
.addOrderBy(`subscription.${sortBy}`, sortOrder, 'NULLS LAST')
.getMany()
return {
subscriptions,
}
} catch (error) {
log.error(error)
return {
errorCodes: [SubscriptionsErrorCode.BadRequest],
}
}
})
export type UnsubscribeSuccessPartial = Merge<
UnsubscribeSuccess,
{ subscription: PartialSubscription }
>
export const unsubscribeResolver = authorized<
UnsubscribeSuccessPartial,
UnsubscribeError,
MutationUnsubscribeArgs
>(async (_, { name, subscriptionId }, { uid, log }) => {
log.info('unsubscribeResolver')
try {
const queryBuilder = getRepository(Subscription)
.createQueryBuilder('subscription')
.leftJoinAndSelect('subscription.newsletterEmail', 'newsletterEmail')
.where({ user: { id: uid } })
if (subscriptionId) {
// if subscriptionId is provided, ignore name
queryBuilder.andWhere({ id: subscriptionId })
} else {
// if subscriptionId is not provided, use name for old clients
queryBuilder.andWhere({ name })
}
const subscription = await queryBuilder.getOne()
if (!subscription) {
return {
errorCodes: [UnsubscribeErrorCode.NotFound],
}
}
if (
subscription.type === SubscriptionType.Newsletter &&
!subscription.unsubscribeMailTo &&
!subscription.unsubscribeHttpUrl
) {
log.info('No unsubscribe method found for newsletter subscription')
}
await unsubscribe(subscription)
analytics.track({
userId: uid,
event: 'unsubscribed',
properties: {
name,
env: env.server.apiEnv,
},
})
return {
subscription,
}
} catch (error) {
log.error('failed to unsubscribe', error)
return {
errorCodes: [UnsubscribeErrorCode.BadRequest],
}
}
})
export type SubscribeSuccessPartial = Merge<
SubscribeSuccess,
{ subscriptions: PartialSubscription[] }
>
export const subscribeResolver = authorized<
SubscribeSuccessPartial,
SubscribeError,
MutationSubscribeArgs
>(async (_, { input }, { uid, log }) => {
try {
analytics.track({
userId: uid,
event: 'subscribed',
properties: {
...input,
env: env.server.apiEnv,
},
})
// find existing subscription
const existingSubscription = await getRepository(Subscription).findOneBy({
url: input.url,
user: { id: uid },
type: SubscriptionType.Rss,
})
if (existingSubscription) {
if (existingSubscription.status === SubscriptionStatus.Active) {
return {
errorCodes: [SubscribeErrorCode.AlreadySubscribed],
}
}
// re-subscribe
const updatedSubscription = await getRepository(Subscription).save({
...existingSubscription,
status: SubscriptionStatus.Active,
})
// create a cloud task to fetch rss feed item for resub subscription
await enqueueRssFeedFetch({
userIds: [uid],
url: input.url,
subscriptionIds: [updatedSubscription.id],
scheduledDates: [new Date()], // fetch immediately
fetchedDates: [updatedSubscription.lastFetchedAt || null],
checksums: [updatedSubscription.lastFetchedChecksum || null],
addToLibraryFlags: [!!updatedSubscription.autoAddToLibrary],
})
return {
subscriptions: [updatedSubscription],
}
}
// create new rss subscription
const MAX_RSS_SUBSCRIPTIONS = env.subscription.feed.max
// validate rss feed
const feed = await parseFeed(input.url)
if (!feed) {
return {
errorCodes: [SubscribeErrorCode.NotFound],
}
}
// limit number of rss subscriptions to max
const results = (await getRepository(Subscription).query(
`insert into omnivore.subscriptions (name, url, description, type, user_id, icon, auto_add_to_library, is_private)
select $1, $2, $3, $4, $5, $6, $7, $8 from omnivore.subscriptions
where user_id = $5 and type = 'RSS' and status = 'ACTIVE'
having count(*) < $9
returning *;`,
[
feed.title,
feed.url,
feed.description || null,
SubscriptionType.Rss,
uid,
feed.thumbnail || null,
input.autoAddToLibrary ?? null,
input.isPrivate ?? null,
MAX_RSS_SUBSCRIPTIONS,
]
)) as any[]
if (results.length === 0) {
return {
errorCodes: [SubscribeErrorCode.ExceededMaxSubscriptions],
}
}
// convert to camel case
const newSubscription = keysToCamelCase(results[0]) as Subscription
// create a cloud task to fetch rss feed item for the new subscription
await enqueueRssFeedFetch({
userIds: [uid],
url: feed.url,
subscriptionIds: [newSubscription.id],
scheduledDates: [new Date()], // fetch immediately
fetchedDates: [null],
checksums: [null],
addToLibraryFlags: [!!newSubscription.autoAddToLibrary],
})
return {
subscriptions: [newSubscription],
}
} catch (error) {
log.error('failed to subscribe', error)
if (error instanceof Error && error.message === 'Status code 404') {
return {
errorCodes: [SubscribeErrorCode.NotFound],
}
}
return {
errorCodes: [SubscribeErrorCode.BadRequest],
}
}
})
export type UpdateSubscriptionSuccessPartial = Merge<
UpdateSubscriptionSuccess,
{ subscription: PartialSubscription }
>
export const updateSubscriptionResolver = authorized<
UpdateSubscriptionSuccessPartial,
UpdateSubscriptionError,
MutationUpdateSubscriptionArgs
>(async (_, { input }, { authTrx, uid, log }) => {
try {
analytics.track({
userId: uid,
event: 'update_subscription',
properties: {
...input,
env: env.server.apiEnv,
},
})
const updatedSubscription = await authTrx(async (t) => {
const repo = t.getRepository(Subscription)
// update subscription
await t.getRepository(Subscription).save({
id: input.id,
name: input.name || undefined,
description: input.description || undefined,
lastFetchedAt: input.lastFetchedAt
? new Date(input.lastFetchedAt)
: undefined,
lastFetchedChecksum: input.lastFetchedChecksum || undefined,
status: input.status || undefined,
scheduledAt: input.scheduledAt
? new Date(input.scheduledAt)
: undefined,
autoAddToLibrary: input.autoAddToLibrary ?? undefined,
isPrivate: input.isPrivate ?? undefined,
})
return repo.findOneByOrFail({
id: input.id,
user: { id: uid },
})
})
return {
subscription: updatedSubscription,
}
} catch (error) {
log.error('failed to update subscription', error)
return {
errorCodes: [UpdateSubscriptionErrorCode.BadRequest],
}
}
})
export const feedsResolver = authorized<
FeedsSuccess,
FeedsError,
QueryFeedsArgs
>(async (_, { input }, { log }) => {
try {
const startCursor = input.after || ''
const start =
startCursor && !isNaN(Number(startCursor)) ? Number(startCursor) : 0
const first = Math.min(input.first || 10, 100) // cap at 100
const { feeds, count } = await feedRepository.searchFeeds(
input.query || '',
first + 1, // fetch one extra to check if there is a next page
start,
input.sort?.by,
input.sort?.order || undefined
)
const hasNextPage = feeds.length > first
const endCursor = String(start + feeds.length - (hasNextPage ? 1 : 0))
if (hasNextPage) {
// remove an extra if exists
feeds.pop()
}
const edges: FeedEdge[] = feeds.map((feed) => ({
node: feed,
cursor: endCursor,
}))
return {
__typename: 'FeedsSuccess',
edges,
pageInfo: {
hasPreviousPage: start > 0,
hasNextPage,
startCursor,
endCursor,
totalCount: count,
},
}
} catch (error) {
log.error('Error fetching feeds', error)
return {
errorCodes: [FeedsErrorCode.BadRequest],
}
}
})
export const scanFeedsResolver = authorized<
ScanFeedsSuccess,
ScanFeedsError,
QueryScanFeedsArgs
>(async (_, { input: { opml, url } }, { log, uid }) => {
analytics.track({
userId: uid,
event: 'scan_feeds',
properties: {
opml,
url,
},
})
if (opml) {
// parse opml
const feeds = parseOpml(opml)
if (!feeds) {
return {
errorCodes: [ScanFeedsErrorCode.BadRequest],
}
}
return {
feeds: feeds.map((feed) => ({
url: feed.url,
title: feed.title,
type: feed.type || 'rss',
})),
}
}
if (!url) {
log.error('Missing opml and url')
return {
errorCodes: [ScanFeedsErrorCode.BadRequest],
}
}
try {
// fetch page content and parse feeds
const response = await axios.get(url, RSS_PARSER_CONFIG)
const content = response.data as string
// check if the content is html or xml
const contentType = response.headers['Content-Type']
const isHtml = contentType?.includes('text/html')
if (isHtml) {
// this is an html page, parse rss feed links
const dom = parseHTML(content).document
// type is application/rss+xml or application/atom+xml
const links = dom.querySelectorAll(
'link[type="application/rss+xml"], link[type="application/atom+xml"]'
)
const feeds = Array.from(links)
.map((link) => {
const href = link.getAttribute('href') || ''
const feedUrl = getAbsoluteUrl(href, url)
return {
url: feedUrl,
title: link.getAttribute('title') || '',
type: 'rss',
}
})
.filter((feed) => feed.url)
return {
feeds,
}
}
// this is the url to an RSS feed
const feed = await parseFeed(url, content)
if (!feed) {
log.error('Failed to parse RSS feed')
return {
feeds: [],
}
}
return {
feeds: [feed],
}
} catch (error) {
log.error('Error scanning URL', error)
return {
errorCodes: [ScanFeedsErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/ban-types */
import { Span } from '@opentelemetry/api'
import { Context as ApolloContext } from 'apollo-server-core'
import * as jwt from 'jsonwebtoken'
import { EntityManager } from 'typeorm'
import winston from 'winston'
import { PubsubClient } from '../pubsub'
export interface Claims {
uid: string
iat: number
userRole?: string
scope?: string // scope is used for api key like page:search
exp?: number
email?: string
system?: boolean
}
export type ClaimsToSet = {
uid: string
userRole?: string | null
}
export interface RequestContext {
log: winston.Logger
claims: Claims | undefined
pubsub: PubsubClient
setAuth: (claims: ClaimsToSet, secret?: string) => Promise<void>
clearAuth: () => void
setClaims: (em: EntityManager, uuid?: string | undefined) => Promise<void>
signToken: (
arg1: string | object | Buffer,
arg2: jwt.Secret
) => Promise<unknown>
authTrx: <TResult>(
cb: (em: EntityManager) => TResult,
userRole?: string
) => Promise<TResult>
tracingSpan: Span
}
export type ResolverContext = ApolloContext<RequestContext>
export type WithDataSourcesContext = {
uid: string
} & ResolverContext

View File

@@ -0,0 +1,33 @@
import { LibraryItemState } from '../../entity/library_item'
import {
MutationUpdatePageArgs,
UpdatePageError,
UpdatePageSuccess,
} from '../../generated/graphql'
import { updateLibraryItem } from '../../services/library_item'
import { authorized, libraryItemToArticle } from '../../utils/helpers'
export const updatePageResolver = authorized<
UpdatePageSuccess,
UpdatePageError,
MutationUpdatePageArgs
>(async (_, { input }, { uid }) => {
const updatedPage = await updateLibraryItem(
input.pageId,
{
title: input.title ?? undefined,
description: input.description ?? undefined,
author: input.byline ?? undefined,
savedAt: input.savedAt ? new Date(input.savedAt) : undefined,
publishedAt: input.publishedAt ? new Date(input.publishedAt) : undefined,
thumbnail: input.previewImage ?? undefined,
state: input.state
? (input.state as unknown as LibraryItemState)
: undefined,
},
uid
)
return {
updatedPage: libraryItemToArticle(updatedPage),
}
})

View File

@@ -0,0 +1,168 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import normalizeUrl from 'normalize-url'
import path from 'path'
import { LibraryItemState } from '../../entity/library_item'
import { UploadFile } from '../../entity/upload_file'
import { env } from '../../env'
import {
MutationUploadFileRequestArgs,
PageType,
UploadFileRequestError,
UploadFileRequestErrorCode,
UploadFileRequestSuccess,
UploadFileStatus,
} from '../../generated/graphql'
import { validateUrl } from '../../services/create_page_save_request'
import {
createLibraryItem,
findLibraryItemByUrl,
updateLibraryItem,
} from '../../services/library_item'
import { analytics } from '../../utils/analytics'
import { authorized, generateSlug } from '../../utils/helpers'
import {
contentReaderForLibraryItem,
generateUploadFilePathName,
generateUploadSignedUrl,
} from '../../utils/uploads'
const isFileUrl = (url: string): boolean => {
const parsedUrl = new URL(url)
return parsedUrl.protocol == 'file:'
}
export const itemTypeForContentType = (contentType: string) => {
if (contentType == 'application/epub+zip') {
return PageType.Book
}
return PageType.File
}
export const uploadFileRequestResolver = authorized<
UploadFileRequestSuccess,
UploadFileRequestError,
MutationUploadFileRequestArgs
>(async (_, { input }, ctx) => {
const { authTrx, uid, log } = ctx
let uploadFileData: { id: string | null } = {
id: null,
}
analytics.track({
userId: uid,
event: 'file_upload_request',
properties: {
url: input.url,
env: env.server.apiEnv,
},
})
let title: string
let fileName: string
try {
const url = normalizeUrl(new URL(input.url).href, {
stripHash: true,
stripWWW: false,
})
title = decodeURI(path.basename(new URL(url).pathname, '.pdf'))
fileName = decodeURI(path.basename(new URL(url).pathname)).replace(
/[^a-zA-Z0-9-_.]/g,
''
)
if (!fileName) {
fileName = 'content.pdf'
}
if (!isFileUrl(url)) {
try {
validateUrl(url)
} catch (error) {
log.info('illegal file input url', error)
return {
errorCodes: [UploadFileRequestErrorCode.BadInput],
}
}
}
} catch {
return { errorCodes: [UploadFileRequestErrorCode.BadInput] }
}
uploadFileData = await authTrx((t) =>
t.getRepository(UploadFile).save({
url: input.url,
user: { id: uid },
fileName,
status: UploadFileStatus.Initialized,
contentType: input.contentType,
})
)
if (uploadFileData.id) {
const uploadFileId = uploadFileData.id
const uploadFilePathName = generateUploadFilePathName(
uploadFileId,
fileName
)
const uploadSignedUrl = await generateUploadSignedUrl(
uploadFilePathName,
input.contentType
)
// If this is a file URL, we swap in a special URL
const attachmentUrl = `https://omnivore.app/attachments/${uploadFilePathName}`
if (isFileUrl(input.url)) {
await authTrx(async (tx) => {
await tx.getRepository(UploadFile).update(uploadFileId, {
url: attachmentUrl,
status: UploadFileStatus.Initialized,
})
})
}
let createdItemId: string | undefined = undefined
if (input.createPageEntry) {
// If we have a file:// URL, don't try to match it
// and create a copy of the item, just create a
// new item.
const item = await findLibraryItemByUrl(input.url, uid)
if (item) {
await updateLibraryItem(
item.id,
{
state: LibraryItemState.Processing,
},
uid
)
createdItemId = item.id
} else {
const itemType = itemTypeForContentType(input.contentType)
const uploadFileId = uploadFileData.id
const item = await createLibraryItem(
{
id: input.clientRequestId || undefined,
originalUrl: isFileUrl(input.url) ? attachmentUrl : input.url,
user: { id: uid },
title,
readableContent: '',
itemType,
uploadFile: { id: uploadFileData.id },
slug: generateSlug(uploadFilePathName),
state: LibraryItemState.Processing,
contentReader: contentReaderForLibraryItem(itemType, uploadFileId),
},
uid
)
createdItemId = item.id
}
}
return {
id: uploadFileData.id,
uploadSignedUrl,
createdPageId: createdItemId,
}
} else {
return { errorCodes: [UploadFileRequestErrorCode.FailedCreate] }
}
})

View File

@@ -0,0 +1,380 @@
import * as jwt from 'jsonwebtoken'
import {
RegistrationType,
StatusType,
User as UserEntity,
} from '../../entity/user'
import { env } from '../../env'
import {
DeleteAccountError,
DeleteAccountErrorCode,
DeleteAccountSuccess,
GoogleSignupResult,
LoginErrorCode,
LoginResult,
LogOutErrorCode,
LogOutResult,
MutationDeleteAccountArgs,
MutationGoogleLoginArgs,
MutationGoogleSignupArgs,
MutationUpdateEmailArgs,
MutationUpdateUserArgs,
MutationUpdateUserProfileArgs,
QueryUserArgs,
QueryValidateUsernameArgs,
ResolverFn,
SignupErrorCode,
UpdateEmailError,
UpdateEmailErrorCode,
UpdateEmailSuccess,
UpdateUserError,
UpdateUserErrorCode,
UpdateUserProfileError,
UpdateUserProfileErrorCode,
UpdateUserProfileSuccess,
UpdateUserSuccess,
User,
UserErrorCode,
UserResult,
UsersError,
UsersSuccess,
} from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { createUser } from '../../services/create_user'
import { sendVerificationEmail } from '../../services/send_emails'
import { updateUser } from '../../services/user'
import { authorized, userDataToUser } from '../../utils/helpers'
import { validateUsername } from '../../utils/usernamePolicy'
import { WithDataSourcesContext } from '../types'
export const updateUserResolver = authorized<
UpdateUserSuccess,
UpdateUserError,
MutationUpdateUserArgs
>(async (_, { input: { name, bio } }, { uid, authTrx }) => {
const user = await userRepository.findById(uid)
if (!user) {
return { errorCodes: [UpdateUserErrorCode.UserNotFound] }
}
const errorCodes = []
if (!name) {
errorCodes.push(UpdateUserErrorCode.EmptyName)
}
if (bio && bio.length > 400) {
errorCodes.push(UpdateUserErrorCode.BioTooLong)
}
if (errorCodes.length > 0) {
return { errorCodes }
}
const updatedUser = await authTrx((tx) =>
tx.getRepository(UserEntity).save({
...user,
name,
profile: {
...user.profile,
bio,
},
})
)
return { user: userDataToUser(updatedUser) }
})
export const updateUserProfileResolver = authorized<
UpdateUserProfileSuccess,
UpdateUserProfileError,
MutationUpdateUserProfileArgs
>(async (_, { input: { userId, username, pictureUrl } }, { uid, authTrx }) => {
const user = await userRepository.findById(userId)
if (!user) {
return { errorCodes: [UpdateUserProfileErrorCode.Unauthorized] }
}
if (user.id !== uid) {
return {
errorCodes: [UpdateUserProfileErrorCode.Forbidden],
}
}
if (!(username || pictureUrl)) {
return {
errorCodes: [UpdateUserProfileErrorCode.BadData],
}
}
const lowerCasedUsername = username?.toLowerCase()
if (lowerCasedUsername) {
const existingUser = await userRepository.findOneBy({
profile: {
username: lowerCasedUsername,
},
status: StatusType.Active,
})
if (existingUser?.id) {
return {
errorCodes: [UpdateUserProfileErrorCode.UsernameExists],
}
}
if (!validateUsername(lowerCasedUsername)) {
return {
errorCodes: [UpdateUserProfileErrorCode.BadUsername],
}
}
}
const updatedUser = await authTrx((tx) =>
tx.getRepository(UserEntity).save({
...user,
profile: {
...user.profile,
username: lowerCasedUsername,
pictureUrl,
},
})
)
return { user: userDataToUser(updatedUser) }
})
export const googleLoginResolver: ResolverFn<
LoginResult,
unknown,
WithDataSourcesContext,
MutationGoogleLoginArgs
> = async (_obj, { input }, { setAuth }) => {
const { email, secret } = input
try {
jwt.verify(secret, env.server.jwtSecret)
} catch {
return { errorCodes: [LoginErrorCode.AuthFailed] }
}
const user = await userRepository.findOneBy({
email,
status: StatusType.Active,
})
if (!user?.id) {
return { errorCodes: [LoginErrorCode.UserNotFound] }
}
// set auth cookie in response header
await setAuth({ uid: user.id })
return { me: userDataToUser(user) }
}
export const validateUsernameResolver: ResolverFn<
boolean,
Record<string, unknown>,
WithDataSourcesContext,
QueryValidateUsernameArgs
> = async (_obj, { username }) => {
const lowerCasedUsername = username.toLowerCase()
if (!validateUsername(lowerCasedUsername)) {
return false
}
const user = await userRepository.findOneBy({
profile: {
username: lowerCasedUsername,
},
})
return !user
}
export const googleSignupResolver: ResolverFn<
GoogleSignupResult,
Record<string, unknown>,
WithDataSourcesContext,
MutationGoogleSignupArgs
> = async (_obj, { input }, { setAuth, log }) => {
const { email, username, name, bio, sourceUserId, pictureUrl, secret } = input
const lowerCasedUsername = username.toLowerCase()
try {
jwt.verify(secret, env.server.jwtSecret)
} catch {
return { errorCodes: [SignupErrorCode.ExpiredToken] }
}
try {
const [user, profile] = await createUser({
email,
sourceUserId,
provider: 'GOOGLE',
name,
username: lowerCasedUsername,
pictureUrl: pictureUrl,
bio: bio || undefined,
inviteCode: undefined,
})
await setAuth({ uid: user.id })
return {
me: userDataToUser({ ...user, profile: { ...profile, private: false } }),
}
} catch (err) {
log.info('error signing up with google', err)
if (isErrorWithCode(err)) {
return { errorCodes: [err.errorCode as SignupErrorCode] }
}
return { errorCodes: [SignupErrorCode.Unknown] }
}
}
export const logOutResolver: ResolverFn<
LogOutResult,
unknown,
WithDataSourcesContext,
unknown
> = (_, __, { clearAuth, log }) => {
try {
clearAuth()
return { message: 'User successfully logged out' }
} catch (error) {
log.error(error)
return { errorCodes: [LogOutErrorCode.LogOutFailed] }
}
}
export const getMeUserResolver: ResolverFn<
User | undefined,
unknown,
WithDataSourcesContext,
unknown
> = async (_obj, __, { claims }) => {
try {
if (!claims?.uid) {
return undefined
}
const user = await userRepository.findById(claims.uid)
if (!user) {
return undefined
}
return userDataToUser(user)
} catch (error) {
return undefined
}
}
export const getUserResolver: ResolverFn<
UserResult,
unknown,
WithDataSourcesContext,
QueryUserArgs
> = async (_obj, { userId: id, username }, { uid }) => {
if (!(id || username)) {
return { errorCodes: [UserErrorCode.BadRequest] }
}
const userId =
id ||
(username &&
(
await userRepository.findOneBy({
profile: { username },
status: StatusType.Active,
})
)?.id)
if (!userId) {
return { errorCodes: [UserErrorCode.UserNotFound] }
}
const userRecord = await userRepository.findById(userId)
if (!userRecord) {
return { errorCodes: [UserErrorCode.UserNotFound] }
}
return { user: userDataToUser(userRecord) }
}
export const getAllUsersResolver = authorized<UsersSuccess, UsersError>(
async (_obj, _params) => {
const users = await userRepository.findTopUsers()
const result = { users: users.map((userData) => userDataToUser(userData)) }
return result
}
)
type ErrorWithCode = {
errorCode: string
}
export function isErrorWithCode(error: unknown): error is ErrorWithCode {
return (
(error as ErrorWithCode).errorCode !== undefined &&
typeof (error as ErrorWithCode).errorCode === 'string'
)
}
export const deleteAccountResolver = authorized<
DeleteAccountSuccess,
DeleteAccountError,
MutationDeleteAccountArgs
>(async (_, { userID }, { log }) => {
// soft delete user
const result = await updateUser(userID, {
status: StatusType.Deleted,
})
if (!result.affected) {
log.error('Error deleting user account')
return {
errorCodes: [DeleteAccountErrorCode.UserNotFound],
}
}
return { userID }
})
export const updateEmailResolver = authorized<
UpdateEmailSuccess,
UpdateEmailError,
MutationUpdateEmailArgs
>(async (_, { input: { email } }, { authTrx, uid, log }) => {
try {
const user = await userRepository.findById(uid)
if (!user) {
return {
errorCodes: [UpdateEmailErrorCode.Unauthorized],
}
}
if (user.source === RegistrationType.Email) {
await authTrx(async (tx) =>
tx.withRepository(userRepository).update(user.id, {
email,
})
)
return { email }
}
const result = await sendVerificationEmail({
id: user.id,
name: user.name,
email,
})
if (!result) {
return {
errorCodes: [UpdateEmailErrorCode.BadRequest],
}
}
return { email, verificationEmailSent: true }
} catch (error) {
log.error('Error updating email', error)
return {
errorCodes: [UpdateEmailErrorCode.BadRequest],
}
}
})

View File

@@ -0,0 +1,168 @@
import { DatabaseError } from 'pg'
import { QueryFailedError } from 'typeorm'
import { UserDeviceToken } from '../../entity/user_device_tokens'
import { env } from '../../env'
import {
DeviceToken,
DeviceTokensError,
DeviceTokensErrorCode,
DeviceTokensSuccess,
MutationSetDeviceTokenArgs,
SetDeviceTokenError,
SetDeviceTokenErrorCode,
SetDeviceTokenSuccess,
} from '../../generated/graphql'
import {
createDeviceToken,
deleteDeviceToken,
findDeviceTokenById,
findDeviceTokenByToken,
findDeviceTokensByUserId,
} from '../../services/user_device_tokens'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
const PG_UNIQUE_CONSTRAINT_VIOLATION = '23505'
export const setDeviceTokenResolver = authorized<
SetDeviceTokenSuccess,
SetDeviceTokenError,
MutationSetDeviceTokenArgs
>(async (_parent, { input }, { uid, log }) => {
const { id, token } = input
if (!id && !token) {
log.error('id or token is required')
return {
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
}
}
try {
// when token is null, we are deleting it
if (!token && id) {
const deviceToken = await findDeviceTokenById(id, uid)
if (!deviceToken) {
log.error('device token not found', id)
return {
errorCodes: [SetDeviceTokenErrorCode.NotFound],
}
}
// delete token
const result = await deleteDeviceToken(id, uid)
if (!result) {
log.error('device token not deleted', id)
return {
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
}
}
analytics.track({
userId: uid,
event: 'device_token_deleted',
properties: {
id: deviceToken.id,
token: deviceToken.token,
env: env.server.apiEnv,
},
})
return {
deviceToken: deviceTokenToData(deviceToken),
}
} else if (token) {
// create token
const deviceToken = await createDeviceToken(uid, token)
analytics.track({
userId: uid,
event: 'device_token_created',
properties: {
id: deviceToken.id,
token: deviceToken.token,
env: env.server.apiEnv,
},
})
return {
deviceToken: deviceTokenToData(deviceToken),
}
}
return {
errorCodes: [SetDeviceTokenErrorCode.BadRequest],
}
} catch (e) {
log.error(e)
if (
e instanceof QueryFailedError &&
(e.driverError as DatabaseError).code ===
PG_UNIQUE_CONSTRAINT_VIOLATION &&
token
) {
// duplicate token
const deviceToken = await findDeviceTokenByToken(token, uid)
if (!deviceToken) {
return {
errorCodes: [SetDeviceTokenErrorCode.NotFound],
}
}
return {
deviceToken: deviceTokenToData(deviceToken),
}
}
return {
errorCodes: [SetDeviceTokenErrorCode.Unauthorized],
}
}
})
export const deviceTokensResolver = authorized<
DeviceTokensSuccess,
DeviceTokensError
>(async (_parent, _args, { claims: { uid }, log }) => {
try {
log.info('deviceTokensResolver', {
labels: {
source: 'resolver',
resolver: 'deviceTokensResolver',
uid,
},
})
const deviceTokens = await findDeviceTokensByUserId(uid)
log.info('deviceTokens', deviceTokens)
return {
deviceTokens: deviceTokens.map(deviceTokenToData),
}
} catch (e) {
log.error('Error getting device tokens', {
e,
labels: {
source: 'resolver',
resolver: 'rulesResolver',
uid,
},
})
return {
errorCodes: [DeviceTokensErrorCode.BadRequest],
}
}
})
const deviceTokenToData = (deviceToken: UserDeviceToken): DeviceToken => {
return {
id: deviceToken.id,
token: deviceToken.token,
createdAt: deviceToken.createdAt,
}
}

View File

@@ -0,0 +1,150 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { FeedArticle, PageInfo } from '../../generated/graphql'
export type PartialFeedArticle = Omit<
FeedArticle,
'sharedBy' | 'article' | 'reactions'
>
type PaginatedFeedArticlesSuccessPartial = {
edges: { cursor: string; node: PartialFeedArticle }[]
pageInfo: PageInfo
}
// export const getSharedArticleResolver: ResolverFn<
// SharedArticleSuccessPartial | SharedArticleError,
// Record<string, unknown>,
// WithDataSourcesContext,
// QuerySharedArticleArgs
// > = async (_obj, { username, slug, selectedHighlightId }, { kx, models }) => {
// try {
// const user = await models.user.getWhere({ username })
// if (!user) {
// return {
// errorCodes: [SharedArticleErrorCode.NotFound],
// }
// }
// const article = await models.userArticle.getBySlug(username, slug)
// if (!article || !article.sharedAt) {
// return {
// errorCodes: [SharedArticleErrorCode.NotFound],
// }
// }
// if (selectedHighlightId) {
// const highlightResult = await models.highlight.getWhereIn('shortId', [
// selectedHighlightId,
// ])
// if (!highlightResult || !highlightResult[0].sharedAt) {
// return {
// errorCodes: [SharedArticleErrorCode.NotFound],
// }
// }
// }
// const shareInfo = await getShareInfoForArticle(
// kx,
// user.id,
// article.id,
// models
// )
// return { article: { ...article, userId: user.id, shareInfo: shareInfo } }
// } catch (error) {
// return { errorCodes: [SharedArticleErrorCode.NotFound] }
// }
// }
// export const getUserFeedArticlesResolver: ResolverFn<
// PaginatedFeedArticlesSuccessPartial,
// unknown,
// WithDataSourcesContext,
// QueryFeedArticlesArgs
// > = async (
// _obj,
// { after: _startCursor, first: _first, sharedByUser },
// { models, claims, authTrx }
// ) => {
// if (!(sharedByUser || claims?.uid)) {
// return {
// edges: [],
// pageInfo: {
// startCursor: '',
// endCursor: '',
// hasNextPage: false,
// hasPreviousPage: false,
// },
// }
// }
// const first = _first || 0
// const startCursor = _startCursor || ''
// const feedArticles =
// (await authTrx((tx) =>
// models.userArticle.getUserFeedArticlesPaginatedWithHighlights(
// { cursor: startCursor, first: first + 1, sharedByUser }, // fetch one more item to get next cursor
// claims?.uid || '',
// tx
// )
// )) || []
// const endCursor = feedArticles[feedArticles.length - 1]?.sharedAt
// .getTime()
// ?.toString()
// const hasNextPage = feedArticles.length > first
// if (hasNextPage) {
// // remove an extra if exists
// feedArticles.pop()
// }
// const edges = feedArticles.map((fa) => {
// return {
// node: fa,
// cursor: fa.sharedAt.getTime()?.toString(),
// }
// })
// return {
// edges,
// pageInfo: {
// hasPreviousPage: false,
// startCursor: '',
// hasNextPage,
// endCursor,
// },
// }
// }
// export const updateSharedCommentResolver = authorized<
// UpdateSharedCommentSuccess,
// UpdateSharedCommentError,
// MutationUpdateSharedCommentArgs
// >(
// async (
// _,
// { input: { articleID, sharedComment } },
// { models, authTrx, claims: { uid } }
// ) => {
// const ua = await authTrx((tx) =>
// models.userArticle.getByParameters(uid, { articleId: articleID }, tx)
// )
// if (!ua) {
// return { errorCodes: [UpdateSharedCommentErrorCode.NotFound] }
// }
// await authTrx((tx) =>
// models.userArticle.updateByArticleId(
// uid,
// articleID,
// { sharedComment },
// tx
// )
// )
// return { articleID, sharedComment }
// }
// )

View File

@@ -0,0 +1,101 @@
// export const setFollowResolver = authorized<
// SetFollowSuccess,
// SetFollowError,
// MutationSetFollowArgs
// >(
// async (
// _,
// { input: { userId: friendUserId, follow } },
// { models, authTrx, claims: { uid } }
// ) => {
// const user = await models.user.getUserDetails(uid, friendUserId)
// if (!user) return { errorCodes: [SetFollowErrorCode.NotFound] }
// const userFriendRecord = await authTrx((tx) =>
// models.userFriends.getByUserFriendId(uid, friendUserId, tx)
// )
// if (follow) {
// if (!userFriendRecord) {
// await authTrx((tx) =>
// models.userFriends.create({ friendUserId, userId: uid }, tx)
// )
// }
// } else if (userFriendRecord) {
// await authTrx((tx) => models.userFriends.delete(userFriendRecord.id, tx))
// }
// const updatedUser = await models.user.getUserDetails(uid, friendUserId)
// if (!updatedUser) return { errorCodes: [SetFollowErrorCode.NotFound] }
// return {
// updatedUser: {
// ...userDataToUser(updatedUser),
// isFriend: updatedUser.viewerIsFollowing,
// },
// }
// }
// )
// const getUserList = async (
// uid: string,
// users: UserData[],
// models: DataModels,
// authTrx: <TResult>(
// cb: (tx: Knex.Transaction) => TResult,
// userRole?: string
// ) => Promise<TResult>
// ): Promise<User[]> => {
// const usersIds = users.map(({ id }) => id)
// const friends = await authTrx((tx) =>
// models.userFriends.getByFriendIds(uid, usersIds, tx)
// )
// const friendsIds = friends.map(({ friendUserId }) => friendUserId)
// users = users.map((f) => ({
// ...f,
// isFriend: friendsIds.includes(f.id),
// viewerIsFollowing: friendsIds.includes(f.id),
// }))
// return users.map((u) => userDataToUser(u))
// }
// export const getFollowersResolver: ResolverFn<
// GetFollowersResult,
// unknown,
// WithDataSourcesContext,
// QueryGetFollowersArgs
// > = async (_parent, { userId }, { models, claims, authTrx }) => {
// const followers = userId
// ? await authTrx((tx) => models.user.getUserFollowersList(userId, tx))
// : []
// if (!claims?.uid) return { followers: usersWithNoFriends(followers) }
// return {
// followers: await getUserList(claims?.uid, followers, models, authTrx),
// }
// }
// export const getFollowingResolver: ResolverFn<
// GetFollowingResult,
// unknown,
// WithDataSourcesContext,
// QueryGetFollowingArgs
// > = async (_parent, { userId }, { models, claims, authTrx }) => {
// const following = userId
// ? await authTrx((tx) => models.user.getUserFollowingList(userId, tx))
// : []
// if (!claims?.uid) return { following: usersWithNoFriends(following) }
// return {
// following: await getUserList(claims?.uid, following, models, authTrx),
// }
// }
// const usersWithNoFriends = (users: UserData[]): User[] => {
// return users.map((f) =>
// userDataToUser({
// ...f,
// isFriend: false,
// } as UserData)
// )
// }

View File

@@ -0,0 +1,66 @@
import { UserPersonalization } from '../../entity/user_personalization'
import {
GetUserPersonalizationError,
GetUserPersonalizationResult,
MutationSetUserPersonalizationArgs,
SetUserPersonalizationError,
SetUserPersonalizationErrorCode,
SetUserPersonalizationSuccess,
SortOrder,
} from '../../generated/graphql'
import { authorized } from '../../utils/helpers'
export const setUserPersonalizationResolver = authorized<
SetUserPersonalizationSuccess,
SetUserPersonalizationError,
MutationSetUserPersonalizationArgs
>(async (_, { input }, { authTrx, uid }) => {
const result = await authTrx(async (t) => {
return t.getRepository(UserPersonalization).upsert(
{
user: { id: uid },
...input,
},
['user']
)
})
if (result.identifiers.length === 0) {
return {
errorCodes: [SetUserPersonalizationErrorCode.NotFound],
}
}
const updatedUserPersonalization = await authTrx((t) =>
t
.getRepository(UserPersonalization)
.findOneBy({ id: result.identifiers[0].id as string })
)
// Cast SortOrder from string to enum
const librarySortOrder =
updatedUserPersonalization?.librarySortOrder as SortOrder
return {
updatedUserPersonalization: {
...updatedUserPersonalization,
librarySortOrder,
},
}
})
export const getUserPersonalizationResolver = authorized<
GetUserPersonalizationResult,
GetUserPersonalizationError
>(async (_parent, _args, { authTrx, uid }) => {
const userPersonalization = await authTrx((t) =>
t.getRepository(UserPersonalization).findOneBy({
user: { id: uid },
})
)
// Cast SortOrder from string to enum
const librarySortOrder = userPersonalization?.librarySortOrder as SortOrder
return { userPersonalization: { ...userPersonalization, librarySortOrder } }
})

View File

@@ -0,0 +1,171 @@
import { Webhook } from '../../entity/webhook'
import { env } from '../../env'
import {
DeleteWebhookError,
DeleteWebhookErrorCode,
DeleteWebhookSuccess,
MutationDeleteWebhookArgs,
MutationSetWebhookArgs,
QueryWebhookArgs,
SetWebhookError,
SetWebhookErrorCode,
SetWebhookSuccess,
Webhook as WebhookResponse,
WebhookError,
WebhookErrorCode,
WebhookEvent,
WebhooksError,
WebhooksErrorCode,
WebhooksSuccess,
WebhookSuccess,
} from '../../generated/graphql'
import { authTrx } from '../../repository'
import { deleteWebhook } from '../../services/webhook'
import { analytics } from '../../utils/analytics'
import { authorized } from '../../utils/helpers'
export const webhooksResolver = authorized<WebhooksSuccess, WebhooksError>(
async (_obj, _params, { uid, log }) => {
try {
const webhooks = await authTrx((t) =>
t.getRepository(Webhook).findBy({
user: { id: uid },
})
)
return {
webhooks: webhooks.map((webhook) => webhookDataToResponse(webhook)),
}
} catch (error) {
log.error(error)
return {
errorCodes: [WebhooksErrorCode.BadRequest],
}
}
}
)
export const webhookResolver = authorized<
WebhookSuccess,
WebhookError,
QueryWebhookArgs
>(async (_, { id }, { authTrx, log }) => {
try {
const webhook = await authTrx((t) =>
t.getRepository(Webhook).findOne({
where: { id },
relations: ['user'],
})
)
if (!webhook) {
return {
errorCodes: [WebhookErrorCode.NotFound],
}
}
return {
webhook: webhookDataToResponse(webhook),
}
} catch (error) {
log.error(error)
return {
errorCodes: [WebhookErrorCode.BadRequest],
}
}
})
export const deleteWebhookResolver = authorized<
DeleteWebhookSuccess,
DeleteWebhookError,
MutationDeleteWebhookArgs
>(async (_, { id }, { uid, log }) => {
try {
const webhook = await deleteWebhook(id, uid)
analytics.track({
userId: uid,
event: 'webhook_delete',
properties: {
webhookId: id,
env: env.server.apiEnv,
},
})
return {
webhook: webhookDataToResponse(webhook),
}
} catch (error) {
log.error('Error deleting webhook', error)
return {
errorCodes: [DeleteWebhookErrorCode.BadRequest],
}
}
})
export const setWebhookResolver = authorized<
SetWebhookSuccess,
SetWebhookError,
MutationSetWebhookArgs
>(async (_, { input }, { authTrx, claims: { uid }, log }) => {
log.info('setWebhookResolver')
try {
const webhookToSave: Partial<Webhook> = {
url: input.url,
eventTypes: input.eventTypes as string[],
method: input.method || 'POST',
contentType: input.contentType || 'application/json',
enabled: input.enabled === null ? true : input.enabled,
}
if (input.id) {
// Update
const existingWebhook = await authTrx((t) =>
t.getRepository(Webhook).findOne({
where: { id: input.id || '' },
relations: ['user'],
})
)
if (!existingWebhook) {
return {
errorCodes: [SetWebhookErrorCode.NotFound],
}
}
webhookToSave.id = input.id
}
const webhook = await authTrx((t) =>
t.getRepository(Webhook).save({
user: { id: uid },
...webhookToSave,
})
)
analytics.track({
userId: uid,
event: 'webhook_set',
properties: {
webhookId: webhook.id,
env: env.server.apiEnv,
},
})
return {
webhook: webhookDataToResponse(webhook),
}
} catch (error) {
log.error(error)
return {
errorCodes: [SetWebhookErrorCode.BadRequest],
}
}
})
const webhookDataToResponse = (webhook: Webhook): WebhookResponse => ({
...webhook,
eventTypes: webhook.eventTypes as WebhookEvent[],
})

View File

@@ -0,0 +1,135 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { htmlToSpeechFile } from '@omnivore/text-to-speech-handler'
import cors from 'cors'
import express from 'express'
import * as jwt from 'jsonwebtoken'
import { Speech } from '../entity/speech'
import { env } from '../env'
import { CreateArticleErrorCode } from '../generated/graphql'
import { Claims } from '../resolvers/types'
import { createPageSaveRequest } from '../services/create_page_save_request'
import { findLibraryItemById } from '../services/library_item'
import { getClaimsByToken } from '../utils/auth'
import { isSiteBlockedForParse } from '../utils/blocked'
import { corsConfig } from '../utils/corsConfig'
import { logger } from '../utils/logger'
import { generateDownloadSignedUrl } from '../utils/uploads'
interface SpeechInput {
voice?: string
secondaryVoice?: string
priority?: 'low' | 'high'
language?: string
}
const outputFormats = ['mp3', 'speech-marks', 'speech']
export function articleRouter() {
const router = express.Router()
router.options('/save', cors<express.Request>({ ...corsConfig, maxAge: 600 }))
router.post('/save', cors<express.Request>(corsConfig), async (req, res) => {
const { url } = req.body as {
url?: string
}
const token = req?.cookies?.auth || req?.headers?.authorization
const claims = await getClaimsByToken(token)
if (!claims) {
return res.status(401).send('UNAUTHORIZED')
}
const { uid } = claims
logger.info('Article saving request', {
body: req.body,
labels: {
source: 'SaveEndpoint',
userId: uid,
},
})
if (!url) {
return res.status(400).send({ errorCode: 'BAD_DATA' })
}
const result = await createPageSaveRequest({ userId: uid, url })
if (isSiteBlockedForParse(url)) {
return res
.status(400)
.send({ errorCode: CreateArticleErrorCode.NotAllowedToParse })
}
if (result.errorCode) {
return res.status(400).send({ errorCode: result.errorCode })
}
return res.send({
articleSavingRequestId: result.id,
url: result.url,
})
})
router.get(
'/:id/:outputFormat',
cors<express.Request>(corsConfig),
async (req, res) => {
const articleId = req.params.id
const outputFormat = req.params.outputFormat
const { voice, secondaryVoice, language } = req.query as SpeechInput
if (!articleId || outputFormats.indexOf(outputFormat) === -1) {
return res.status(400).send('Invalid data')
}
const token = req.cookies?.auth || req.headers?.authorization
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
}
const { uid } = jwt.decode(token) as Claims
if (!uid) {
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
}
logger.info(`Get article speech in ${outputFormat} format`, {
params: req.params,
labels: {
userId: uid,
source: `GetArticleSpeech-${outputFormat}`,
},
})
try {
const item = await findLibraryItemById(articleId, uid)
if (!item) {
return res.status(404).send('Page not found')
}
const speechFile = htmlToSpeechFile({
title: item.title,
content: item.readableContent,
options: {
primaryVoice: voice,
secondaryVoice: secondaryVoice,
language: language || item.itemLanguage || undefined,
},
})
return res.send({ ...speechFile, pageId: articleId })
} catch (error) {
logger.error('Error getting article speech:', error)
res.status(500).send({ errorCode: 'INTERNAL_ERROR' })
}
}
)
return router
}
const redirectUrl = async (speech: Speech, outputFormat: string) => {
switch (outputFormat) {
case 'mp3':
return generateDownloadSignedUrl(speech.audioFileName)
case 'speech-marks':
return generateDownloadSignedUrl(speech.speechMarksFileName)
default:
return generateDownloadSignedUrl(speech.audioFileName)
}
}

View File

@@ -0,0 +1,214 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import * as jwt from 'jsonwebtoken'
import jwksClient from 'jwks-rsa'
import { env, homePageURL } from '../../env'
import { LoginErrorCode } from '../../generated/graphql'
import { userRepository } from '../../repository/user'
import { logger } from '../../utils/logger'
import { createSsoToken, ssoRedirectURL } from '../../utils/sso'
import { DecodeTokenResult } from './auth_types'
import {
createPendingUserToken,
createWebAuthToken,
suggestedUsername,
} from './jwt_helpers'
import { analytics } from '../../utils/analytics'
import { StatusType } from '../../entity/user'
const appleBaseURL = 'https://appleid.apple.com'
const audienceName = 'app.omnivore.app'
const webAudienceName = 'app.omnivore'
async function fetchApplePublicKey(kid: string): Promise<string | null> {
const client = jwksClient({
cache: true,
jwksUri: `${appleBaseURL}/auth/keys`,
})
try {
const key: jwksClient.SigningKey = await new Promise((resolve, reject) => {
client.getSigningKey(kid, (error, result) => {
if (error) {
return reject(error)
}
return resolve(result)
})
})
return key.getPublicKey()
} catch (e) {
logger.error('fetchApplePublicKey error', e)
return null
}
}
export async function decodeAppleToken(
token: string
): Promise<DecodeTokenResult> {
const decodedToken = jwt.decode(token, { complete: true })
const { kid, alg } = (decodedToken as any).header
try {
const publicKey = await fetchApplePublicKey(kid)
if (!publicKey) {
return { errorCode: 500 }
}
const jwtClaims: any = jwt.verify(token, publicKey, { algorithms: [alg] })
const issVerified = (jwtClaims.iss ?? '') === appleBaseURL
const audience = jwtClaims.aud ?? ''
const audVerified = audience == webAudienceName || audience === audienceName
if (issVerified && audVerified && jwtClaims.email) {
return {
email: jwtClaims.email,
sourceUserId: jwtClaims.sub,
name: jwtClaims.name,
}
} else {
return {
errorCode: 401,
}
}
} catch (e) {
logger.error('decodeAppleToken error', e)
return { errorCode: 500 }
}
}
type AppleWebAuthResponse = {
redirectURL: string
authToken?: string
pendingUserToken?: string
}
type AppleUserData = {
name?: AppleUserName
email?: string
}
type AppleUserName = {
firstName?: string
lastName?: string
}
export async function handleAppleWebAuth(
idToken: string,
appleUserData?: AppleUserData,
isLocal = false,
isVercel = false
): Promise<AppleWebAuthResponse> {
const baseURL = () => {
if (isLocal) {
return 'http://localhost:3000'
}
if (isVercel) {
return homePageURL()
}
return env.client.url
}
const decodedTokenResult = await decodeAppleToken(idToken)
const authFailedRedirect = `${baseURL()}/login?errorCodes=${
LoginErrorCode.AuthFailed
}`
if (!decodedTokenResult.email || decodedTokenResult.errorCode) {
return Promise.resolve({
redirectURL: authFailedRedirect,
})
}
try {
const user = await userRepository.findOneBy({
sourceUserId: decodedTokenResult.sourceUserId,
source: 'APPLE',
status: StatusType.Active,
})
const userId = user?.id
if (!userId) {
// create a temp token so the user can create a new profile
const payload = await createTempAppleUserPayload({
authFailedRedirect,
appleUserData,
baseURL: baseURL(),
sourceUserId: decodedTokenResult.sourceUserId,
email: decodedTokenResult.email,
})
return payload
}
const authToken = await createWebAuthToken(userId)
if (authToken) {
const ssoToken = createSsoToken(authToken, `${baseURL()}/home`)
const redirectURL = isVercel
? ssoRedirectURL(ssoToken)
: `${baseURL()}/home`
analytics.track({
userId: user.id,
event: 'login',
properties: {
method: 'apple',
email: user.email,
username: user.profile.username,
env: env.server.apiEnv,
},
})
return {
authToken,
redirectURL,
}
} else {
return { redirectURL: authFailedRedirect }
}
} catch (e) {
logger.info('handleAppleWebAuth error', e)
return { redirectURL: authFailedRedirect }
}
}
type CreateTempAppleUserPayloadInputs = {
appleUserData?: AppleUserData
authFailedRedirect: string
baseURL: string
sourceUserId?: string
email?: string
}
async function createTempAppleUserPayload(
inputs: CreateTempAppleUserPayloadInputs
): Promise<AppleWebAuthResponse> {
if (!inputs.email || !inputs.sourceUserId) {
throw new Error('missing email or sourceUserId')
}
const firstName = inputs.appleUserData?.name?.firstName ?? ''
const lastName = inputs.appleUserData?.name?.lastName ?? ''
const name = `${firstName} ${lastName}`
const username = suggestedUsername(name)
try {
const pendingUserToken = await createPendingUserToken({
email: inputs.email,
sourceUserId: inputs.sourceUserId,
provider: 'APPLE',
name,
username,
})
if (!pendingUserToken) {
throw new Error('Failed to create pending user token')
}
return {
redirectURL: `${inputs.baseURL}/confirm-profile?username=${username}&name=${name}`,
pendingUserToken,
}
} catch {
return { redirectURL: inputs.authFailedRedirect }
}
}

View File

@@ -0,0 +1,739 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-floating-promises */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import axios from 'axios'
import cors from 'cors'
import type { Request, Response } from 'express'
import express from 'express'
import * as jwt from 'jsonwebtoken'
import url from 'url'
import { promisify } from 'util'
import { appDataSource } from '../../data_source'
import { RegistrationType, StatusType, User } from '../../entity/user'
import { env } from '../../env'
import { LoginErrorCode, SignupErrorCode } from '../../generated/graphql'
import { getRepository, setClaims } from '../../repository'
import { userRepository } from '../../repository/user'
import { isErrorWithCode } from '../../resolvers'
import { createUser } from '../../services/create_user'
import {
sendConfirmationEmail,
sendPasswordResetEmail,
} from '../../services/send_emails'
import { analytics } from '../../utils/analytics'
import {
comparePassword,
getClaimsByToken,
hashPassword,
setAuthInCookie,
} from '../../utils/auth'
import { corsConfig } from '../../utils/corsConfig'
import { logger } from '../../utils/logger'
import { createSsoToken, ssoRedirectURL } from '../../utils/sso'
import { handleAppleWebAuth } from './apple_auth'
import type { AuthProvider } from './auth_types'
import {
generateGoogleLoginURL,
googleAuth,
handleGoogleWebAuth,
validateGoogleUser,
} from './google_auth'
import { createWebAuthToken } from './jwt_helpers'
import { createMobileAccountCreationResponse } from './mobile/account_creation'
import rateLimit from 'express-rate-limit'
export interface SignupRequest {
email: string
password: string
name: string
username: string
bio?: string
pictureUrl?: string
}
const signToken = promisify(jwt.sign)
const cookieParams = {
httpOnly: true,
maxAge: 365 * 24 * 60 * 60 * 1000,
}
export const isValidSignupRequest = (obj: any): obj is SignupRequest => {
return (
'email' in obj &&
obj.email.trim().length > 0 &&
obj.email.trim().length < 512 && // email must not be empty
'password' in obj &&
obj.password.length >= 8 &&
obj.password.trim().length < 512 && // password must be at least 8 characters
'name' in obj &&
obj.name.trim().length > 0 &&
obj.name.trim().length < 512 && // name must not be empty
'username' in obj &&
obj.username.trim().length > 0 &&
obj.username.trim().length < 512 // username must not be empty
)
}
// The hourly limiter is used on the create account,
// and reset password endpoints
// this limits users to five operations per an hour
const hourlyLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
skip: (req) => env.dev.isLocal,
})
export function authRouter() {
const router = express.Router()
router.post('/apple-redirect', curriedAuthHandler('APPLE', false))
router.post('/gauth-redirect', curriedAuthHandler('GOOGLE', false))
router.post(
'/vercel/apple-redirect',
curriedAuthHandler('APPLE', false, true)
)
router.post(
'/vercel/gauth-redirect',
curriedAuthHandler('GOOGLE', false, true)
)
router.post(
'/apple-redirect-localhost',
curriedAuthHandler('APPLE', true, true)
)
router.post(
'/gauth-redirect-localhost',
curriedAuthHandler('GOOGLE', true, true)
)
router.options(
'/create-account',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/create-account',
hourlyLimiter,
cors<express.Request>(corsConfig),
async (req, res) => {
const { name, bio, username } = req.body
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const token = req.cookies?.pendingUserAuth as string | undefined
const payload = await createMobileAccountCreationResponse(token, {
name,
username,
bio,
})
if (payload.json.authToken) {
res.cookie('auth', payload.json.authToken, cookieParams)
res.clearCookie('pendingUserAuth')
}
res.status(payload.statusCode).json({})
}
)
function curriedAuthHandler(
provider: AuthProvider,
isLocal: boolean,
isVercel = false
): (req: Request, res: Response) => void {
return (req: Request, res: Response) =>
authHandler(req, res, provider, isLocal, isVercel)
}
async function authHandler(
req: Request,
res: Response,
provider: AuthProvider,
isLocal: boolean,
isVercel: boolean
) {
const completion = (
res: Response,
redirectURL: string,
jwt?: string,
pendingUserJwt?: string
) => {
if (jwt) {
res.cookie('auth', jwt, cookieParams)
}
if (pendingUserJwt) {
res.cookie('pendingUserAuth', pendingUserJwt, cookieParams)
}
return res.redirect(redirectURL)
}
if (provider === 'APPLE') {
const { id_token, user } = req.body
const authResponse = await handleAppleWebAuth(
id_token,
user,
isLocal,
isVercel
)
completion(
res,
authResponse.redirectURL,
authResponse.authToken,
authResponse.pendingUserToken
)
return
}
if (provider === 'GOOGLE') {
const { credential } = req.body
const authResponse = await handleGoogleWebAuth(
credential,
isLocal,
isVercel
)
completion(
res,
authResponse.redirectURL,
authResponse.authToken,
authResponse.pendingUserAuth
)
return
}
res.status(500).send('Unknown provider')
}
router.options(
'/verify',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.get('/verify', cors<express.Request>(corsConfig), async (req, res) => {
// return 'AUTHENTICATED', 'PENDING_USER', or 'NOT_AUTHENTICATED'
if (req.cookies?.auth || req.headers['authorization']) {
res.status(200).json({ authStatus: 'AUTHENTICATED' })
} else if (req.cookies?.pendingUserAuth || req.headers['pendingUserAuth']) {
res.status(200).json({ authStatus: 'PENDING_USER' })
} else {
res.status(200).json({ authStatus: 'NOT_AUTHENTICATED' })
}
})
// Remove code below this line once we update google auth to new version
router.get('/google-redirect/login', async (req, res) => {
let redirect_uri = ''
if (req.query.redirect_uri) {
redirect_uri = encodeURIComponent(req.query.redirect_uri as string)
}
const state = JSON.stringify({ redirect_uri })
res.redirect(
generateGoogleLoginURL(
googleAuth(),
`/api/auth/google-login/login`,
state
)
)
})
router.get('/google-login/login', async (req, res) => {
const { code } = req.query
const userData = await validateGoogleUser(`${code}`)
if (!userData || !userData.email || !userData.id) {
return { errorCodes: [SignupErrorCode.GoogleAuthError] }
}
const user = await userRepository.findOneBy({ email: userData.email })
// eslint-disable-next @typescript-eslint/ban-ts-comment
const secret = (await signToken(
{ email: userData.email },
env.server.jwtSecret,
// @ts-ignore
{
expiresIn: 300,
}
)) as string
if (!user) {
return res.redirect(
`${env.client.url}/join?email=${userData.email}&name=${userData.name}&sourceUserId=${userData.id}&pictureUrl=${userData.picture}&secret=${secret}`
)
}
if (user.source !== RegistrationType.Google) {
const errorCodes = [LoginErrorCode.WrongSource]
return res.redirect(
`${env.client.url}/${
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req.params as any)?.action
}?errorCodes=${errorCodes}`
)
}
const query = `
mutation googleLogin{
googleLogin(input: {
secret: "${secret}",
email: "${userData.email}",
}) {
__typename
... on LoginError { errorCodes }
... on LoginSuccess {
me {
id
name
profile {
pictureUrl
}
}
}
}
}`
const result = await axios.post(env.server.gateway_url + '/graphql', {
query,
})
const { data } = result.data
if (data.googleLogin.__typename === 'LoginError') {
if (data.googleLogin.errorCodes.includes(LoginErrorCode.UserNotFound)) {
return res.redirect(`${env.client.url}/login`)
}
const errorCodes = data.googleLogin.errorCodes.join(',')
return res.redirect(
`${env.client.url}/${
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req.params as any)?.action
}?errorCodes=${errorCodes}`
)
}
if (!result.headers['set-cookie']) {
return res.redirect(
`${env.client.url}/${
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req.params as any)?.action
}?errorCodes=unknown`
)
}
analytics.track({
userId: user.id,
event: 'login',
properties: {
method: 'google',
email: user.email,
username: user.profile.username,
env: env.server.apiEnv,
},
})
res.setHeader('set-cookie', result.headers['set-cookie'])
await handleSuccessfulLogin(req, res, user, data.googleLogin.newUser)
})
async function handleSuccessfulLogin(
req: express.Request,
res: express.Response,
user: User,
newUser: boolean
): Promise<void> {
try {
let redirectUri: string | null = null
if (req.query.state) {
// Google login case: redirect_uri is in query state param.
try {
const state = JSON.parse((req.query?.state || '') as string)
redirectUri = state?.redirect_uri
} catch (err) {
logger.error(
'handleSuccessfulLogin: failed to parse redirect query state param',
err
)
}
}
if (newUser) {
if (redirectUri && redirectUri !== '/') {
redirectUri = url.resolve(
env.client.url,
decodeURIComponent(redirectUri)
)
} else {
redirectUri = `${env.client.url}/home`
}
}
redirectUri = redirectUri ? redirectUri : `${env.client.url}/home`
const message = res.get('Message')
if (message) {
const u = new URL(redirectUri)
u.searchParams.append('message', message)
redirectUri = u.toString()
}
// If we do have an auth token, we want to try redirecting to the
// sso endpoint which will set a cookie for the client domain (omnivore.app)
// after we set a cookie for the API domain (api-prod.omnivore.app)
const authToken = await createWebAuthToken(user.id)
if (authToken) {
const ssoToken = createSsoToken(authToken, redirectUri)
redirectUri = ssoRedirectURL(ssoToken)
}
await setAuthInCookie({ uid: user.id }, res)
return res.redirect(redirectUri)
} catch (error) {
logger.info('handleSuccessfulLogin exception:', error)
return res.redirect(`${env.client.url}/login?errorCodes=AUTH_FAILED`)
}
}
router.options(
'/email-login',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/email-login',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
interface LoginRequest {
email: string
password: string
}
function isValidLoginRequest(obj: any): obj is LoginRequest {
return (
'email' in obj &&
obj.email.trim().length > 0 && // email must not be empty
'password' in obj &&
obj.password.length >= 8 // password must be at least 8 characters
)
}
if (!isValidLoginRequest(req.body)) {
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
)
}
const { email, password } = req.body
try {
const user = await userRepository.findByEmail(email.trim())
if (!user || user.status === StatusType.Deleted) {
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.UserNotFound}`
)
}
if (user.status === StatusType.Pending && user.email) {
await sendConfirmationEmail({
id: user.id,
email: user.email,
name: user.name,
})
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=PENDING_VERIFICATION`
)
}
if (!user?.password) {
// user has no password, so they need to set one
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.WrongSource}`
)
}
// check if password is correct
const validPassword = await comparePassword(password, user.password)
if (!validPassword) {
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=${LoginErrorCode.InvalidCredentials}`
)
}
analytics.track({
userId: user.id,
event: 'login',
properties: {
method: 'email',
email: user.email,
username: user.profile.username,
env: env.server.apiEnv,
},
})
await handleSuccessfulLogin(req, res, user, false)
} catch (e) {
logger.info('email-login exception:', e)
res.redirect(
`${env.client.url}/auth/email-login?errorCodes=AUTH_FAILED`
)
}
}
)
router.options(
'/email-signup',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/email-signup',
hourlyLimiter,
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
if (!isValidSignupRequest(req.body)) {
return res.redirect(
`${env.client.url}/auth/email-signup?errorCodes=INVALID_CREDENTIALS`
)
}
const { email, password, name, username, bio, pictureUrl } = req.body
// trim whitespace in email address
const trimmedEmail = email.trim()
try {
// hash password
const hashedPassword = await hashPassword(password)
await createUser({
email: trimmedEmail,
provider: 'EMAIL',
sourceUserId: trimmedEmail,
name: name.trim(),
username: username.trim().toLowerCase(), // lowercase username
pictureUrl,
bio,
password: hashedPassword,
pendingConfirmation: true,
})
res.redirect(
`${env.client.url}/auth/verify-email?message=SIGNUP_SUCCESS`
)
} catch (e) {
logger.info('email-signup exception:', e)
if (isErrorWithCode(e)) {
return res.redirect(
`${env.client.url}/auth/email-signup?errorCodes=${e.errorCode}`
)
}
res.redirect(`${env.client.url}/auth/email-signup?errorCodes=UNKNOWN`)
}
}
)
router.options(
'/confirm-email',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/confirm-email',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const token = req.body.token
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=INVALID_TOKEN`
)
}
const user = await getRepository(User).findOneBy({ id: claims.uid })
if (!user) {
return res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=USER_NOT_FOUND`
)
}
if (user.status === StatusType.Pending) {
const updated = await appDataSource.transaction(
async (entityManager) => {
await setClaims(entityManager, user.id)
return entityManager
.getRepository(User)
.update({ id: user.id }, { status: StatusType.Active })
}
)
if (!updated.affected) {
return res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=UNKNOWN`
)
}
}
analytics.track({
userId: user.id,
event: 'login',
properties: {
method: 'email_verification',
email: user.email,
username: user.profile.username,
env: env.server.apiEnv,
},
})
res.set('Message', 'EMAIL_CONFIRMED')
await handleSuccessfulLogin(req, res, user, false)
} catch (e) {
logger.info('confirm-email exception:', e)
if (e instanceof jwt.TokenExpiredError) {
return res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=TOKEN_EXPIRED`
)
}
res.redirect(
`${env.client.url}/auth/confirm-email?errorCodes=INVALID_TOKEN`
)
}
}
)
router.options(
'/forgot-password',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/forgot-password',
hourlyLimiter,
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const email = req.body.email?.trim() as string // trim whitespace
if (!email) {
return res.redirect(
`${env.client.url}/auth/forgot-password?errorCodes=INVALID_EMAIL`
)
}
try {
const user = await userRepository.findByEmail(email)
if (!user || user.status === StatusType.Deleted) {
return res.redirect(`${env.client.url}/auth/reset-sent`)
}
if (user.status === StatusType.Pending) {
return res.redirect(`${env.client.url}/auth/reset-sent`)
}
if (!(await sendPasswordResetEmail(user))) {
return res.redirect(
`${env.client.url}/auth/forgot-password?errorCodes=INVALID_EMAIL`
)
}
res.clearCookie('auth')
res.clearCookie('pendingUserAuth')
res.redirect(`${env.client.url}/auth/reset-sent`)
} catch (e) {
logger.info('forgot-password exception:', e)
res.redirect(
`${env.client.url}/auth/forgot-password?errorCodes=UNKNOWN`
)
}
}
)
router.options(
'/reset-password',
cors<express.Request>({ ...corsConfig, maxAge: 600 })
)
router.post(
'/reset-password',
cors<express.Request>(corsConfig),
async (req: express.Request, res: express.Response) => {
const { token, password } = req.body
try {
// verify token
const claims = await getClaimsByToken(token)
if (!claims) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_TOKEN`
)
}
if (!password || password.length < 8) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=INVALID_PASSWORD`
)
}
const user = await getRepository(User).findOneBy({
id: claims.uid,
})
if (!user) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=USER_NOT_FOUND`
)
}
if (user.status === StatusType.Pending) {
return res.redirect(
`${env.client.url}/auth/email-login?errorCodes=PENDING_VERIFICATION`
)
}
const hashedPassword = await hashPassword(password)
const updated = await appDataSource.transaction(
async (entityManager) => {
await setClaims(entityManager, user.id)
return entityManager.getRepository(User).update(user.id, {
password: hashedPassword,
email: claims.email ?? undefined, // update email address if it was provided
source: RegistrationType.Email, // reset password will always be email
})
}
)
if (!updated.affected) {
return res.redirect(
`${env.client.url}/auth/reset-password/${token}?errorCodes=UNKNOWN`
)
}
analytics.track({
userId: user.id,
event: 'login',
properties: {
method: 'password_reset',
email: user.email,
username: user.profile.username,
env: env.server.apiEnv,
},
})
await handleSuccessfulLogin(req, res, user, false)
} catch (e) {
logger.info('reset-password exception:', e)
if (e instanceof jwt.TokenExpiredError) {
return res.redirect(
`${env.client.url}/auth/reset-password/?errorCodes=TOKEN_EXPIRED`
)
}
res.redirect(
`${env.client.url}/auth/reset-password/?errorCodes=INVALID_TOKEN`
)
}
}
)
return router
}

View File

@@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type JsonResponsePayload = {
statusCode: number
json: any
}
export type DecodeTokenResult = {
errorCode?: 401 | 500
email?: string
sourceUserId?: string
name?: string
}
export type AuthProvider = 'APPLE' | 'GOOGLE' | 'EMAIL'
export type UserProfile = {
username: string
name: string
bio?: string
}
export type PendingUserTokenPayload = {
email: string
sourceUserId: string
provider: AuthProvider
name: string
username: string
}
// Type guard for PendingUserTokenPayload
export function isPendingUserTokenPayload(
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
object: any
): object is PendingUserTokenPayload {
return (
'email' in object &&
'sourceUserId' in object &&
'provider' in object &&
'name' in object &&
'username' in object
)
}
export type IntegrationTokenPayload = {
uid: string
token: string
}

Some files were not shown because too many files have changed in this diff Show More