Compare commits

..

18 Commits

Author SHA1 Message Date
EYHN
27989c6401 fix(core): fix error when switch to local workspace (#6144) 2024-03-15 18:06:34 +08:00
LongYinan
d8b57d00c0 Merge remote-tracking branch 'origin/canary' into beta 2024-03-15 17:01:17 +08:00
DarkSky
530959b868 fix(server): wrap read-modify-write apis with distributed lock (#5979) 2024-03-15 16:46:41 +08:00
Chen
dd2c6cf544 fix: note added with template should be edgeless only (#6122) 2024-03-15 16:46:41 +08:00
EYHN
f5b1d041c5 fix(core): fix active view undefined (#6131)
close https://github.com/toeverything/AFFiNE/issues/6127, #6132
2024-03-15 16:46:40 +08:00
EYHN
81f3e65bde feat(core): allow switch workspace in loading fallback (#6129) 2024-03-15 16:46:40 +08:00
EYHN
4389378689 fix(core): catch auth error (#6128) 2024-03-15 16:46:39 +08:00
Cats Juice
364bb6ccb0 fix(core): shared page's present button not working (#6117) 2024-03-15 16:46:39 +08:00
Peng Xiao
e47b271e9d fix: update docs (#6094) 2024-03-15 16:46:38 +08:00
LongYinan
72a4bf5294 ci: fix canary backend auto release job (#6121) 2024-03-15 16:46:38 +08:00
liuyi
f5108c6788 feat(server): cleanup gateway code (#6118) 2024-03-15 16:46:37 +08:00
liuyi
f9945a073c feat(server): allow prefetch doc stats before sync (#6115) 2024-03-15 16:46:37 +08:00
LongYinan
1b1e40133a Merge remote-tracking branch 'origin/canary' into beta 2024-03-14 15:31:51 +08:00
DarkSky
003986d657 feat: add cloud logger sa integrate (#6089) 2024-03-12 23:25:00 +08:00
forehalo
0d66519523 ci: only enable jwst codec in canary 2024-03-12 17:47:33 +08:00
LongYinan
aaffc80f82 ci: add write packages permission to release workflow 2024-03-06 18:18:27 +08:00
LongYinan
2c8861ae49 ci: add write permission to release workflow 2024-03-06 18:14:22 +08:00
LongYinan
ec1edfd70b ci: add write permission to release workflow 2024-03-06 18:12:20 +08:00
677 changed files with 9155 additions and 13882 deletions

View File

@@ -1,4 +1,4 @@
const { join } = require('node:path');
const { resolve } = require('node:path');
const createPattern = packageName => [
{
@@ -31,6 +31,11 @@ const createPattern = packageName => [
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
importNames: ['mergeUpdates'],
},
{
group: ['@affine/env/constant'],
message:
@@ -88,17 +93,16 @@ const config = {
},
ecmaVersion: 'latest',
sourceType: 'module',
project: join(__dirname, 'tsconfig.eslint.json'),
project: resolve(__dirname, './tsconfig.eslint.json'),
},
plugins: [
'react',
'@typescript-eslint',
'simple-import-sort',
'sonarjs',
'import-x',
'i',
'unused-imports',
'unicorn',
'rxjs',
],
rules: {
'array-callback-return': 'error',
@@ -131,7 +135,6 @@ const config = {
'unused-imports/no-unused-imports': 'error',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
'import-x/no-duplicates': 'error',
'@typescript-eslint/ban-ts-comment': [
'error',
{
@@ -165,6 +168,11 @@ const config = {
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
importNames: ['mergeUpdates'],
},
],
},
],
@@ -204,21 +212,6 @@ const config = {
'sonarjs/no-collection-size-mischeck': 'error',
'sonarjs/no-useless-catch': 'error',
'sonarjs/no-identical-functions': 'error',
'rxjs/finnish': [
'error',
{
functions: false,
methods: false,
strict: true,
types: {
'^LiveData$': true,
// some yjs classes are Observables, but they don't need to be in Finnish notation
'^Doc$': false, // yjs Doc
'^Awareness$': false, // yjs Awareness
'^UndoManager$': false, // yjs UndoManager
},
},
],
},
overrides: [
{
@@ -235,6 +228,9 @@ const config = {
},
...allPackages.map(pkg => ({
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`],
parserOptions: {
project: resolve(__dirname, './tsconfig.eslint.json'),
},
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
@@ -251,7 +247,7 @@ const config = {
],
'@typescript-eslint/no-misused-promises': ['error'],
'@typescript-eslint/prefer-readonly': 'error',
'import-x/no-extraneous-dependencies': ['error'],
'i/no-extraneous-dependencies': ['error'],
'react-hooks/exhaustive-deps': [
'warn',
{

View File

@@ -21,6 +21,7 @@ const {
AFFINE_GOOGLE_CLIENT_ID,
AFFINE_GOOGLE_CLIENT_SECRET,
CLOUD_SQL_IAM_ACCOUNT,
CLOUD_LOGGER_IAM_ACCOUNT,
GCLOUD_CONNECTION_NAME,
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
REDIS_HOST,
@@ -59,7 +60,9 @@ const createHelmCommand = ({ isDryRun }) => {
? [
`--set-json web.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json graphql.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json graphql.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
`--set-json sync.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json sync.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
`--set-json cloud-sql-proxy.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`,
`--set-json cloud-sql-proxy.nodeSelector=\"{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }\"`,
]
@@ -111,7 +114,7 @@ const createHelmCommand = ({ isDryRun }) => {
`--set-string graphql.app.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`,
`--set-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`,
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`,
`--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`,
`--set graphql.app.experimental.enableJwstCodec=${isInternal}`,
`--set graphql.app.features.earlyAccessPreview=false`,
`--set graphql.app.features.syncClientVersionCheck=true`,
`--set sync.replicaCount=${syncReplicaCount}`,

View File

@@ -11,7 +11,7 @@ runs:
- name: Download tar.gz
uses: actions/download-artifact@v4
with:
name: web
name: core
path: .
- name: Extract core artifacts

View File

@@ -1,6 +1,6 @@
FROM openresty/openresty:1.25.3.1-0-buster
WORKDIR /app
COPY ./packages/frontend/web/dist ./dist
COPY ./packages/frontend/core/dist ./dist
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf

View File

@@ -1,7 +1,7 @@
FROM node:20-bookworm-slim
COPY ./packages/backend/server /app
COPY ./packages/frontend/web/dist /app/static
COPY ./packages/frontend/core/dist /app/static
WORKDIR /app
RUN apt-get update && \

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.14.0"
appVersion: "0.12.0"

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.14.0"
appVersion: "0.12.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -61,3 +61,18 @@ Create the name of the service account to use
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{- define "jwt.key" -}}
{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.app.jwt.secretName -}}
{{- if and $secret $secret.data.private -}}
{{/*
Reusing existing secret data
*/}}
key: {{ $secret.data.private }}
{{- else -}}
{{/*
Generate new data
*/}}
key: {{ genPrivateKey "ecdsa" | b64enc }}
{{- end -}}
{{- end -}}

View File

@@ -28,10 +28,10 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
- name: AUTH_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
name: "{{ .Values.app.jwt.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
@@ -45,6 +45,8 @@ spec:
value: "graphql"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:

View File

@@ -0,0 +1,7 @@
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.jwt.secretName }}"
type: Opaque
data:
{{- ( include "jwt.key" . ) | indent 2 -}}

View File

@@ -1,18 +0,0 @@
{{- $privateKey := default (genPrivateKey "ecdsa") .Values.global.secret.privateKey | b64enc | quote }}
{{- if not .Values.global.secret.privateKey }}
{{- $existingKey := (lookup "v1" "Secret" .Release.Namespace .Values.global.secret.secretName) }}
{{- if $existingKey }}
{{- $privateKey = index $existingKey.data "key" }}
{{- end -}}
{{- end -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.global.secret.secretName }}
annotations:
"helm.sh/resource-policy": "keep"
type: Opaque
data:
key: {{ $privateKey }}

View File

@@ -19,6 +19,10 @@ app:
https: true
doc:
mergeInterval: "3000"
jwt:
secretName: jwt-private-key
# base64 encoded ecdsa private key
privateKey: ''
captcha:
enable: false
secretName: captcha

View File

@@ -3,7 +3,7 @@ name: sync
description: AFFiNE Sync Server
type: application
version: 0.0.0
appVersion: "0.14.0"
appVersion: "0.12.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -32,11 +32,6 @@ spec:
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: AFFINE_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.global.secret.secretName }}"
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NO_COLOR
@@ -45,6 +40,8 @@ spec:
value: "affine"
- name: SERVER_FLAVOR
value: "sync"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD

View File

@@ -12,6 +12,7 @@ env: 'production'
app:
# AFFINE_SERVER_HOST
host: '0.0.0.0'
serviceAccount:
create: true
annotations: {}

View File

@@ -4,9 +4,6 @@ global:
className: ''
host: affine.pro
tls: []
secret:
secretName: 'server-private-key'
privateKey: ''
database:
user: 'postgres'
url: 'pg-postgresql'

View File

@@ -266,8 +266,8 @@ jobs:
path: ./packages/backend/storage/storage.node
if-no-files-found: error
build-web:
name: Build @affine/web
build-core:
name: Build @affine/core
runs-on: ubuntu-latest
steps:
@@ -277,15 +277,15 @@ jobs:
with:
electron-install: false
full-cache: true
- name: Build Web
- name: Build Core
# always skip cache because its fast, and cache configuration is always changing
run: yarn nx build @affine/web --skip-nx-cache
- name: zip web
run: tar -czf dist.tar.gz --directory=packages/frontend/web/dist .
- name: Upload web artifact
run: yarn nx build @affine/core --skip-nx-cache
- name: zip core
run: tar -czf dist.tar.gz --directory=packages/frontend/core/dist .
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: web
name: core
path: dist.tar.gz
if-no-files-found: error
@@ -485,7 +485,7 @@ jobs:
test: true,
}
needs:
- build-web
- build-core
- build-native
steps:
- uses: actions/checkout@v4
@@ -516,8 +516,8 @@ jobs:
shell: bash
run: yarn workspace @affine/electron vitest
- name: Download web artifact
uses: ./.github/actions/download-web
- name: Download core artifact
uses: ./.github/actions/download-core
with:
path: packages/frontend/electron/resources/web-static

View File

@@ -15,7 +15,6 @@ on:
env:
APP_NAME: affine
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
jobs:
build-server:
@@ -39,8 +38,8 @@ jobs:
name: server-dist
path: ./packages/backend/server/dist
if-no-files-found: error
build-web:
name: Build @affine/web
build-core:
name: Build @affine/core
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -51,7 +50,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/web --skip-nx-cache
run: yarn nx build @affine/core --skip-nx-cache
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
@@ -65,15 +64,15 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
- name: Upload web artifact
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: web
path: ./packages/frontend/web/dist
name: core
path: ./packages/frontend/core/dist
if-no-files-found: error
build-web-selfhost:
name: Build @affine/web selfhost
build-core-selfhost:
name: Build @affine/core selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -84,7 +83,7 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/web --skip-nx-cache
run: yarn nx build @affine/core --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
@@ -92,11 +91,11 @@ jobs:
SELF_HOSTED: true
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload web artifact
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/web/dist
name: selfhost-core
path: ./packages/frontend/core/dist
if-no-files-found: error
build-storage:
@@ -144,16 +143,16 @@ jobs:
packages: 'write'
needs:
- build-server
- build-web
- build-web-selfhost
- build-core
- build-core-selfhost
- build-storage
steps:
- uses: actions/checkout@v4
- name: Download core artifact
uses: actions/download-artifact@v4
with:
name: web
path: ./packages/frontend/web/dist
name: core
path: ./packages/frontend/core/dist
- name: Download server dist
uses: actions/download-artifact@v4
with:
@@ -219,14 +218,14 @@ jobs:
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Remove web dist
run: rm -rf ./packages/frontend/web/dist
- name: Remove core dist
run: rm -rf ./packages/frontend/core/dist
- name: Download selfhost web artifact
- name: Download selfhost core artifact
uses: actions/download-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/web/dist
name: selfhost-core
path: ./packages/frontend/core/dist
- name: Install Node.js dependencies
run: |
@@ -296,6 +295,7 @@ jobs:
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}
CLOUD_LOGGER_IAM_ACCOUNT: ${{ secrets.CLOUD_LOGGER_IAM_ACCOUNT }}
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }}

View File

@@ -33,7 +33,6 @@ env:
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
jobs:
before-make:
@@ -61,10 +60,10 @@ jobs:
SKIP_PLUGIN_BUILD: 'true'
SKIP_NX_CACHE: 'true'
- name: Upload web artifact
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: web
name: core
path: packages/frontend/electron/resources/web-static
make-distribution:
@@ -111,7 +110,7 @@ jobs:
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: web
name: core
path: packages/frontend/electron/resources/web-static
- name: Build Desktop Layers
@@ -189,7 +188,7 @@ jobs:
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v4
with:
name: web
name: core
path: packages/frontend/electron/resources/web-static
- name: Build Desktop Layers
@@ -318,7 +317,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: web
name: core
path: web-static
- name: Zip web-static
run: zip -r web-static.zip web-static

View File

@@ -55,18 +55,20 @@ When logging in via email, you will see the mail arriving at localhost:8025 in a
```
DATABASE_URL="postgresql://affine:affine@localhost:5432/affine"
NEXTAUTH_URL="http://localhost:8080"
MAILER_SENDER="noreply@toeverything.info"
MAILER_USER="auth"
MAILER_PASSWORD="auth"
MAILER_HOST="localhost"
MAILER_PORT="1025"
STRIPE_API_KEY=sk_live_1
STRIPE_WEBHOOK_KEY=1
```
## Prepare prisma
```
yarn workspace @affine/server prisma db push
yarn workspace @affine/server data-migration run
```
Note, you may need to do it again if db schema changed.

View File

@@ -19,5 +19,5 @@
],
"ext": "ts,md,json"
},
"version": "0.14.0"
"version": "0.12.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.14.0",
"version": "0.12.0",
"private": true,
"author": "toeverything",
"license": "MIT",
@@ -17,20 +17,20 @@
"node": "<21.0.0"
},
"scripts": {
"dev": "yarn workspace @affine/cli dev",
"dev": "dev-core",
"dev:electron": "yarn workspace @affine/electron dev",
"build": "yarn nx build @affine/web",
"build": "yarn nx build @affine/core",
"build:electron": "yarn nx build @affine/electron",
"build:storage": "yarn nx run-many -t build -p @affine/storage",
"build:storybook": "yarn nx build @affine/storybook",
"start:web-static": "yarn workspace @affine/web static-server",
"start:web-static": "yarn workspace @affine/core static-server",
"start:storybook": "yarn exec serve tests/storybook/storybook-static -l 6006",
"serve:test-static": "yarn exec serve tests/fixtures --cors -p 8081",
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --ext .js,mjs,.ts,.tsx --cache",
"lint:eslint": "eslint . --ext .js,mjs,.ts,.tsx --cache",
"lint:eslint:fix": "yarn lint:eslint --fix",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
@@ -44,7 +44,7 @@
"*": "prettier --write --ignore-unknown --cache",
"*.{ts,tsx,mjs,js,jsx}": [
"prettier --ignore-unknown --write",
"cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint --cache --fix"
"eslint --cache --fix"
],
"*.toml": [
"taplo format"
@@ -61,7 +61,7 @@
"@faker-js/faker": "^8.4.1",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.5.0",
"@nx/vite": "18.1.2",
"@nx/vite": "18.0.8",
"@playwright/test": "^1.41.2",
"@taplo/cli": "^0.7.0",
"@testing-library/react": "^14.2.1",
@@ -74,30 +74,28 @@
"@vanilla-extract/vite-plugin": "^4.0.4",
"@vanilla-extract/webpack-plugin": "^2.3.6",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-istanbul": "1.4.0",
"@vitest/ui": "1.4.0",
"cross-env": "^7.0.3",
"@vitest/coverage-istanbul": "1.3.1",
"@vitest/ui": "1.3.1",
"electron": "^29.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import-x": "^0.4.1",
"eslint-plugin-i": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-sonarjs": "^0.24.0",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-vue": "^9.22.0",
"fake-indexeddb": "5.0.2",
"happy-dom": "^14.0.0",
"happy-dom": "^13.4.1",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"msw": "^2.2.1",
"nanoid": "^5.0.6",
"nx": "^18.0.4",
"nyc": "^15.1.0",
"oxlint": "0.2.14",
"oxlint": "0.0.22",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"serve": "^14.2.1",
@@ -107,7 +105,7 @@
"vite": "^5.1.4",
"vite-plugin-istanbul": "^6.0.0",
"vite-plugin-static-copy": "^1.0.1",
"vitest": "1.4.0",
"vitest": "1.3.1",
"vitest-fetch-mock": "^0.2.2",
"vitest-mock-extended": "^1.3.1"
},
@@ -170,7 +168,7 @@
"which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
"@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0",
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
"macos-alias": "npm:@napi-rs/macos-alias@latest",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
}

View File

@@ -1,11 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[user_id,plan]` on the table `user_subscriptions` will be added. If there are existing duplicate values, this will fail.
*/
-- DropIndex
DROP INDEX "user_subscriptions_user_id_key";
-- CreateIndex
CREATE UNIQUE INDEX "user_subscriptions_user_id_plan_key" ON "user_subscriptions"("user_id", "plan");

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.14.0",
"version": "0.12.0",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -20,7 +20,7 @@
"dependencies": {
"@apollo/server": "^4.10.0",
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-s3": "^3.536.0",
"@aws-sdk/client-s3": "^3.515.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@google-cloud/opentelemetry-resource-util": "^2.1.0",
@@ -63,7 +63,7 @@
"dotenv-cli": "^7.3.0",
"express": "^4.18.2",
"file-type": "^19.0.0",
"get-stream": "^9.0.0",
"get-stream": "^8.0.1",
"graphql": "^16.8.1",
"graphql-scalars": "^1.22.4",
"graphql-type-json": "^0.3.2",
@@ -71,7 +71,6 @@
"ioredis": "^5.3.2",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"mixpanel": "^0.18.0",
"nanoid": "^5.0.6",
"nest-commander": "^3.12.5",
"nestjs-throttler-storage-redis": "^0.4.1",
@@ -103,7 +102,6 @@
"@types/graphql-upload": "^16.0.7",
"@types/keyv": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/mixpanel": "^2.14.8",
"@types/node": "^20.11.20",
"@types/nodemailer": "^6.4.14",
"@types/on-headers": "^1.0.3",

View File

@@ -24,7 +24,7 @@ model User {
features UserFeatures[]
customer UserStripeCustomer?
subscriptions UserSubscription[]
subscription UserSubscription?
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[]
@@ -369,7 +369,7 @@ model UserStripeCustomer {
model UserSubscription {
id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar(36)
userId String @unique @map("user_id") @db.VarChar(36)
plan String @db.VarChar(20)
// yearly/monthly
recurring String @db.VarChar(20)
@@ -395,7 +395,6 @@ model UserSubscription {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, plan])
@@map("user_subscriptions")
}

View File

@@ -1,10 +1,7 @@
import { execSync } from 'node:child_process';
import { generateKeyPairSync } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'dotenv';
const SELF_HOST_CONFIG_DIR = '/root/.affine/config';
/**
* @type {Array<{ from: string; to?: string, modifier?: (content: string): string }>}
@@ -39,26 +36,6 @@ function prepare() {
});
}
}
// make the default .env
if (to === '.env') {
const dotenvFile = fs.readFileSync(targetFilePath, 'utf-8');
const envs = parse(dotenvFile);
// generate a new private key
if (!envs.AFFINE_PRIVATE_KEY) {
const privateKey = generateKeyPairSync('ec', {
namedCurve: 'prime256v1',
}).privateKey.export({
type: 'sec1',
format: 'pem',
});
fs.writeFileSync(
targetFilePath,
`AFFINE_PRIVATE_KEY=${privateKey}\n` + dotenvFile
);
}
}
}
}

View File

@@ -18,8 +18,11 @@ import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
import type { AvailablePlugins } from './fundamentals/config';
import { Config, ConfigModule } from './fundamentals/config';
import {
type AvailablePlugins,
Config,
ConfigModule,
} from './fundamentals/config';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { HelpersModule } from './fundamentals/helpers';

View File

@@ -43,12 +43,5 @@ export async function createApp() {
app.useWebSocketAdapter(adapter);
}
if (AFFiNE.isSelfhosted && AFFiNE.telemetry.enabled) {
const mixpanel = await import('mixpanel');
mixpanel.init(AFFiNE.telemetry.token).track('selfhost-server-started', {
version: AFFiNE.version,
});
}
return app;
}

View File

@@ -39,15 +39,7 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
}
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment', {
stripe: {
keys: {
// fake the key to ensure the server generate full GraphQL Schema even env vars are not set
APIKey: '1',
webhookKey: '1',
},
},
});
AFFiNE.plugins.use('payment');
AFFiNE.plugins.use('oauth');
if (AFFiNE.deploy) {

View File

@@ -52,18 +52,6 @@ AFFiNE.port = 3010;
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
// AFFiNE.metrics.enabled = true;
//
// /* Authentication Settings */
// /* User Signup password limitation */
// AFFiNE.auth.password = {
// minLength: 8,
// maxLength: 32,
// };
//
// /* How long the login session would last by default */
// AFFiNE.auth.session = {
// ttl: 15 * 24 * 60 * 60, // 15 days
// };
//
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */
// AFFiNE.graphql = {
@@ -96,15 +84,15 @@ AFFiNE.port = 3010;
// /* Redis Plugin */
// /* Provide caching and session storing backed by Redis. */
// /* Useful when you deploy AFFiNE server in a cluster. */
// AFFiNE.plugins.use('redis', {
// /* override options */
// });
AFFiNE.plugins.use('redis', {
/* override options */
});
//
//
// /* Payment Plugin */
// AFFiNE.plugins.use('payment', {
// stripe: { keys: {}, apiVersion: '2023-10-16' },
// });
AFFiNE.plugins.use('payment', {
stripe: { keys: {}, apiVersion: '2023-10-16' },
});
//
//
// /* Cloudflare R2 Plugin */

View File

@@ -6,7 +6,6 @@ import {
Controller,
Get,
Header,
HttpStatus,
Post,
Query,
Req,
@@ -14,7 +13,11 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
import {
Config,
PaymentRequiredException,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -30,6 +33,7 @@ class SignInCredential {
@Controller('/api/auth')
export class AuthController {
constructor(
private readonly config: Config,
private readonly url: URLHelper,
private readonly auth: AuthService,
private readonly user: UserService,
@@ -54,13 +58,14 @@ export class AuthController {
}
if (credential.password) {
validators.assertValidPassword(credential.password);
const user = await this.auth.signIn(
credential.email,
credential.password
);
await this.auth.setCookie(req, res, user);
res.status(HttpStatus.OK).send(user);
res.send(user);
} else {
// send email magic link
const user = await this.user.findUserByEmail(credential.email);
@@ -73,7 +78,7 @@ export class AuthController {
throw new Error('Failed to send sign-in email.');
}
res.status(HttpStatus.OK).send({
res.send({
email: credential.email,
});
}
@@ -137,7 +142,6 @@ export class AuthController {
}
email = decodeURIComponent(email);
token = decodeURIComponent(token);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
@@ -158,6 +162,22 @@ export class AuthController {
return this.url.safeRedirect(res, redirectUri);
}
@Get('/authorize')
async authorize(
@CurrentUser() user: CurrentUser,
@Query('redirect_uri') redirect_uri?: string
) {
const session = await this.auth.createUserSession(
user,
undefined,
this.config.auth.accessToken.ttl
);
this.url.link(redirect_uri ?? '/open-app/redirect', {
token: session.sessionId,
});
}
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {

View File

@@ -11,7 +11,7 @@ import {
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import { getRequestResponseFromContext } from '../../fundamentals';
import { Config, getRequestResponseFromContext } from '../../fundamentals';
import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) {
@@ -27,6 +27,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
private auth!: AuthService;
constructor(
private readonly config: Config,
private readonly ref: ModuleRef,
private readonly reflector: Reflector
) {}
@@ -42,6 +43,17 @@ export class AuthGuard implements CanActivate, OnModuleInit {
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
// backward compatibility for client older then 0.12
// TODO: remove
if (!sessionToken) {
sessionToken =
req.cookies[
this.config.https
? '__Secure-next-auth.session-token'
: 'next-auth.session-token'
];
}
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}

View File

@@ -5,7 +5,7 @@ import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
import { TokenService } from './token';
@Module({
imports: [FeatureModule, UserModule],
@@ -17,5 +17,5 @@ export class AuthModule {}
export * from './guard';
export { ClientTokenType } from './resolver';
export { AuthService, TokenService, TokenType };
export { AuthService };
export * from './current-user';

View File

@@ -17,7 +17,6 @@ import {
import type { Request, Response } from 'express';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -49,7 +48,6 @@ export class AuthResolver {
constructor(
private readonly config: Config,
private readonly auth: AuthService,
private readonly user: UserService,
private readonly token: TokenService
) {}
@@ -134,7 +132,7 @@ export class AuthResolver {
@Args('email') email: string,
@Args('password') password: string
) {
validators.assertValidEmail(email);
validators.assertValidCredential({ email, password });
const user = await this.auth.signIn(email, password);
await this.auth.setCookie(ctx.req, ctx.res, user);
ctx.req.user = user;
@@ -167,7 +165,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
await this.auth.changePassword(user.id, newPassword);
await this.auth.changePassword(user.email, newPassword);
return user;
}
@@ -321,7 +319,7 @@ export class AuthResolver {
throw new ForbiddenException('Invalid token');
}
const hasRegistered = await this.user.findUserByEmail(email);
const hasRegistered = await this.auth.getUserByEmail(email);
if (hasRegistered) {
if (hasRegistered.id !== user.id) {

View File

@@ -2,39 +2,37 @@ import {
BadRequestException,
Injectable,
NotAcceptableException,
NotFoundException,
OnApplicationBootstrap,
} from '@nestjs/common';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, type User } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import { Config, CryptoHelper, MailService } from '../../fundamentals';
import {
Config,
CryptoHelper,
MailService,
SessionCache,
} from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
export function parseAuthUserSeqNum(value: any) {
let seq: number = 0;
switch (typeof value) {
case 'number': {
seq = value;
break;
return value;
}
case 'string': {
const result = value.match(/^([\d{0, 10}])$/);
if (result?.[1]) {
seq = Number(result[1]);
}
break;
value = Number.parseInt(value);
return Number.isNaN(value) ? 0 : value;
}
default: {
seq = 0;
return 0;
}
}
return Math.max(0, seq);
}
export function sessionUser(
@@ -58,9 +56,10 @@ export class AuthService implements OnApplicationBootstrap {
sameSite: 'lax',
httpOnly: true,
path: '/',
domain: this.config.host,
secure: this.config.https,
};
static readonly sessionCookieName = 'affine_session';
static readonly sessionCookieName = 'sid';
static readonly authUserSeqHeaderName = 'x-auth-user';
constructor(
@@ -69,7 +68,8 @@ export class AuthService implements OnApplicationBootstrap {
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly user: UserService,
private readonly crypto: CryptoHelper
private readonly crypto: CryptoHelper,
private readonly cache: SessionCache
) {}
async onApplicationBootstrap() {
@@ -89,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap {
email: string,
password: string
): Promise<CurrentUser> {
const user = await this.user.findUserByEmail(email);
const user = await this.getUserByEmail(email);
if (user) {
throw new BadRequestException('Email was taken');
@@ -110,12 +110,12 @@ export class AuthService implements OnApplicationBootstrap {
const user = await this.user.findUserWithHashedPasswordByEmail(email);
if (!user) {
throw new NotAcceptableException('Invalid sign in credentials');
throw new NotFoundException('User Not Found');
}
if (!user.password) {
throw new NotAcceptableException(
'User Password is not set. Should login through email link.'
'User Password is not set. Should login throw email link.'
);
}
@@ -125,12 +125,28 @@ export class AuthService implements OnApplicationBootstrap {
);
if (!passwordMatches) {
throw new NotAcceptableException('Invalid sign in credentials');
throw new NotAcceptableException('Incorrect Password');
}
return sessionUser(user);
}
async getUserWithCache(token: string, seq = 0) {
const cacheKey = `session:${token}:${seq}`;
let user = await this.cache.get<CurrentUser | null>(cacheKey);
if (user) {
return user;
}
user = await this.getUser(token, seq);
if (user) {
await this.cache.set(cacheKey, user);
}
return user;
}
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
const session = await this.getSession(token);
@@ -181,16 +197,7 @@ export class AuthService implements OnApplicationBootstrap {
// Session
// | { user: LimitedUser { email, avatarUrl }, expired: true }
// | { user: User, expired: false }
return session.userSessions
.map(userSession => {
// keep users in the same order as userSessions
const user = users.find(({ id }) => id === userSession.userId);
if (!user) {
return null;
}
return sessionUser(user);
})
.filter(Boolean) as CurrentUser[];
return users.map(sessionUser);
}
async signOut(token: string, seq = 0) {
@@ -219,10 +226,6 @@ export class AuthService implements OnApplicationBootstrap {
}
async getSession(token: string) {
if (!token) {
return null;
}
return this.db.$transaction(async tx => {
const session = await tx.session.findUnique({
where: {
@@ -299,11 +302,10 @@ export class AuthService implements OnApplicationBootstrap {
}
}
async setCookie(_req: Request, res: Response, user: { id: string }) {
async setCookie(req: Request, res: Response, user: { id: string }) {
const session = await this.createUserSession(
user
// TODO(@forehalo): enable multi user session
// req.cookies[AuthService.sessionCookieName]
user,
req.cookies[AuthService.sessionCookieName]
);
res.cookie(AuthService.sessionCookieName, session.sessionId, {
@@ -312,8 +314,12 @@ export class AuthService implements OnApplicationBootstrap {
});
}
async changePassword(id: string, newPassword: string): Promise<User> {
const user = await this.user.findUserById(id);
async getUserByEmail(email: string) {
return this.user.findUserByEmail(email);
}
async changePassword(email: string, newPassword: string): Promise<User> {
const user = await this.getUserByEmail(email);
if (!user) {
throw new BadRequestException('Invalid email');
@@ -332,7 +338,11 @@ export class AuthService implements OnApplicationBootstrap {
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.user.findUserById(id);
const user = await this.db.user.findUnique({
where: {
id,
},
});
if (!user) {
throw new BadRequestException('Invalid email');

View File

@@ -1,7 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import { CryptoHelper } from '../../fundamentals/helpers';
@@ -82,15 +81,4 @@ export class TokenService {
return valid ? record : null;
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
cleanExpiredTokens() {
return this.db.verificationToken.deleteMany({
where: {
expiresAt: {
lte: new Date(),
},
},
});
}
}

View File

@@ -22,20 +22,6 @@ export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
@ObjectType()
export class PasswordLimitsType {
@Field()
minLength!: number;
@Field()
maxLength!: number;
}
@ObjectType()
export class CredentialsRequirementType {
@Field()
password!: PasswordLimitsType;
}
@ObjectType()
export class ServerConfigType {
@Field({
@@ -61,11 +47,6 @@ export class ServerConfigType {
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field(() => CredentialsRequirementType, {
description: 'credentials requirement',
})
credentialsRequirement!: CredentialsRequirementType;
}
export class ServerConfigResolver {
@@ -84,9 +65,6 @@ export class ServerConfigResolver {
// this field should be removed after frontend feature flags implemented
flavor: AFFiNE.type,
features: Array.from(ENABLED_FEATURES),
credentialsRequirement: {
password: AFFiNE.auth.password,
},
};
}
}

View File

@@ -4,8 +4,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../fundamentals';
import { Config, metrics, OnEvent } from '../../fundamentals';
import {
Config,
type EventPayload,
metrics,
OnEvent,
} from '../../fundamentals';
import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';

View File

@@ -16,12 +16,12 @@ import {
transact,
} from 'yjs';
import type { EventPayload } from '../../fundamentals';
import {
Cache,
CallTimer,
Config,
EventEmitter,
type EventPayload,
mergeUpdatesInApplyWay as jwstMergeUpdates,
metrics,
OnEvent,
@@ -55,16 +55,6 @@ export function isEmptyBuffer(buf: Buffer): boolean {
const MAX_SEQ_NUM = 0x3fffffff; // u31
const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates';
interface DocResponse {
doc: Doc;
timestamp: number;
}
interface BinaryResponse {
binary: Buffer;
timestamp: number;
}
/**
* Since we can't directly save all client updates into database, in which way the database will overload,
* we need to buffer the updates and merge them to reduce db write.
@@ -282,15 +272,11 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
updates: Buffer[],
retryTimes = 10
) {
const timestamp = await new Promise<number>((resolve, reject) => {
const lastSeq = await this.getUpdateSeq(workspaceId, guid, updates.length);
const now = Date.now();
let timestamp = now;
await new Promise<void>((resolve, reject) => {
defer(async () => {
const lastSeq = await this.getUpdateSeq(
workspaceId,
guid,
updates.length
);
const now = Date.now();
let timestamp = now;
let turn = 0;
const batchCount = 10;
for (const batch of chunk(updates, batchCount)) {
@@ -317,16 +303,14 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
turn++;
}
return timestamp;
})
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: timestamp => {
next: () => {
this.logger.debug(
`pushed ${updates.length} updates for ${guid} in workspace ${workspaceId}`
);
resolve(timestamp);
resolve();
},
error: e => {
this.logger.error('Failed to push updates', e);
@@ -342,8 +326,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* Get latest timestamp of all docs in the workspace.
*/
@CallTimer('doc', 'get_doc_timestamps')
async getDocTimestamps(workspaceId: string, after: number | undefined = 0) {
@CallTimer('doc', 'get_stats')
async getStats(workspaceId: string, after: number | undefined = 0) {
const snapshots = await this.db.snapshot.findMany({
where: {
workspaceId,
@@ -388,18 +372,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc with all update applied.
*/
async get(workspaceId: string, guid: string): Promise<DocResponse | null> {
async get(workspaceId: string, guid: string): Promise<Doc | null> {
const result = await this._get(workspaceId, guid);
if (result) {
if ('doc' in result) {
return result;
} else {
const doc = await this.recoverDoc(result.binary);
return {
doc,
timestamp: result.timestamp,
};
return result.doc;
} else if ('snapshot' in result) {
return this.recoverDoc(result.snapshot);
}
}
@@ -409,19 +388,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc binary with all update applied.
*/
async getBinary(
workspaceId: string,
guid: string
): Promise<BinaryResponse | null> {
async getBinary(workspaceId: string, guid: string): Promise<Buffer | null> {
const result = await this._get(workspaceId, guid);
if (result) {
if ('doc' in result) {
return {
binary: Buffer.from(encodeStateAsUpdate(result.doc)),
timestamp: result.timestamp,
};
} else {
return result;
return Buffer.from(encodeStateAsUpdate(result.doc));
} else if ('snapshot' in result) {
return result.snapshot;
}
}
@@ -431,27 +404,16 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* get the latest doc state vector with all update applied.
*/
async getDocState(
workspaceId: string,
guid: string
): Promise<BinaryResponse | null> {
async getState(workspaceId: string, guid: string): Promise<Buffer | null> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
const { doc, timestamp } = await this.squash(snapshot, updates);
return {
binary: Buffer.from(encodeStateVector(doc)),
timestamp,
};
const doc = await this.squash(snapshot, updates);
return Buffer.from(encodeStateVector(doc));
}
return snapshot?.state
? {
binary: snapshot.state,
timestamp: snapshot.updatedAt.getTime(),
}
: null;
return snapshot ? snapshot.state : null;
}
/**
@@ -619,17 +581,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
private async _get(
workspaceId: string,
guid: string
): Promise<DocResponse | BinaryResponse | null> {
): Promise<{ doc: Doc } | { snapshot: Buffer } | null> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
return this.squash(snapshot, updates);
return {
doc: await this.squash(snapshot, updates),
};
}
return snapshot
? { binary: snapshot.blob, timestamp: snapshot.updatedAt.getTime() }
: null;
return snapshot ? { snapshot: snapshot.blob } : null;
}
/**
@@ -637,10 +599,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* and delete the updates records at the same time.
*/
@CallTimer('doc', 'squash')
private async squash(
snapshot: Snapshot | null,
updates: Update[]
): Promise<DocResponse> {
private async squash(snapshot: Snapshot | null, updates: Update[]) {
if (!updates.length) {
throw new Error('No updates to squash');
}
@@ -699,7 +658,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
await this.updateCachedUpdatesCount(workspaceId, id, -count);
}
return { doc, timestamp: last.createdAt.getTime() };
return doc;
}
private async getUpdateSeq(workspaceId: string, guid: string, batch = 1) {

View File

@@ -1,8 +1,11 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../fundamentals';
import { OnEvent, PrismaTransaction } from '../../fundamentals';
import {
type EventPayload,
OnEvent,
PrismaTransaction,
} from '../../fundamentals';
import { FeatureKind } from '../features';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';

View File

@@ -1,13 +1,13 @@
import { Injectable } from '@nestjs/common';
import type {
BlobInputType,
EventPayload,
StorageProvider,
} from '../../../fundamentals';
import {
type BlobInputType,
Cache,
EventEmitter,
type EventPayload,
type ListObjectsMetadata,
OnEvent,
type StorageProvider,
StorageProviderFactory,
} from '../../../fundamentals';
@@ -17,15 +17,13 @@ export class WorkspaceBlobStorage {
constructor(
private readonly event: EventEmitter,
private readonly storageFactory: StorageProviderFactory,
private readonly cache: Cache
private readonly storageFactory: StorageProviderFactory
) {
this.provider = this.storageFactory.create('blob');
}
async put(workspaceId: string, key: string, blob: BlobInputType) {
await this.provider.put(`${workspaceId}/${key}`, blob);
await this.cache.delete(`blob-list:${workspaceId}`);
}
async get(workspaceId: string, key: string) {
@@ -33,16 +31,6 @@ export class WorkspaceBlobStorage {
}
async list(workspaceId: string) {
const cachedList = await this.cache.list<ListObjectsMetadata>(
`blob-list:${workspaceId}`,
0,
-1
);
if (cachedList.length > 0) {
return cachedList;
}
const blobs = await this.provider.list(workspaceId + '/');
blobs.forEach(item => {
@@ -50,8 +38,6 @@ export class WorkspaceBlobStorage {
item.key = item.key.slice(workspaceId.length + 1);
});
await this.cache.pushBack(`blob-list:${workspaceId}`, ...blobs);
return blobs;
}

View File

@@ -246,10 +246,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
): Promise<EventResponse<Record<string, number>>> {
this.assertInWorkspace(client, Sync(workspaceId));
const stats = await this.docManager.getDocTimestamps(
workspaceId,
timestamp
);
const stats = await this.docManager.getStats(workspaceId, timestamp);
return {
data: stats,
@@ -305,15 +302,13 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
guid: string;
stateVector?: string;
}
): Promise<
EventResponse<{ missing: string; state?: string; timestamp: number }>
> {
): Promise<EventResponse<{ missing: string; state?: string }>> {
this.assertInWorkspace(client, Sync(workspaceId));
const docId = new DocID(guid, workspaceId);
const res = await this.docManager.get(docId.workspace, docId.guid);
const doc = await this.docManager.get(docId.workspace, docId.guid);
if (!res) {
if (!doc) {
return {
error: new DocNotFoundError(workspaceId, docId.guid),
};
@@ -321,17 +316,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
const missing = Buffer.from(
encodeStateAsUpdate(
res.doc,
doc,
stateVector ? Buffer.from(stateVector, 'base64') : undefined
)
).toString('base64');
const state = Buffer.from(encodeStateVector(res.doc)).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
return {
data: {
missing,
state,
timestamp: res.timestamp,
},
};
}

View File

@@ -7,15 +7,14 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, type User } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { isNil, omitBy } from 'lodash-es';
import type { FileUpload } from '../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
PaymentRequiredException,
Throttle,
} from '../../fundamentals';

View File

@@ -5,13 +5,12 @@ function getAuthCredentialValidator() {
const email = z.string().email({ message: 'Invalid email address' });
let password = z.string();
const minPasswordLength = AFFiNE.node.prod ? 8 : 1;
password = password
.min(AFFiNE.auth.password.minLength, {
message: `Password must be ${AFFiNE.auth.password.minLength} or more charactors long`,
.min(minPasswordLength, {
message: `Password must be ${minPasswordLength} or more charactors long`,
})
.max(AFFiNE.auth.password.maxLength, {
message: `Password must be ${AFFiNE.auth.password.maxLength} or fewer charactors long`,
});
.max(20, { message: 'Password must be 20 or fewer charactors long' });
return z
.object({

View File

@@ -51,7 +51,7 @@ export class WorkspacesController {
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toUTCString());
res.setHeader('last-modified', metadata.lastModified.toISOString());
res.setHeader('content-length', metadata.contentLength);
} else {
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
@@ -83,12 +83,9 @@ export class WorkspacesController {
throw new ForbiddenException('Permission denied');
}
const binResponse = await this.docManager.getBinary(
docId.workspace,
docId.guid
);
const update = await this.docManager.getBinary(docId.workspace, docId.guid);
if (!binResponse) {
if (!update) {
throw new NotFoundException('Doc not found');
}
@@ -109,12 +106,8 @@ export class WorkspacesController {
}
res.setHeader('content-type', 'application/octet-stream');
res.setHeader(
'last-modified',
new Date(binResponse.timestamp).toUTCString()
);
res.setHeader('cache-control', 'private, max-age=2592000');
res.send(binResponse.binary);
res.setHeader('cache-control', 'no-cache');
res.send(update);
}
@Get('/:id/docs/:guid/histories/:timestamp')
@@ -149,7 +142,7 @@ export class WorkspacesController {
if (history) {
res.setHeader('content-type', 'application/octet-stream');
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
res.send(history.blob);
} else {
throw new NotFoundException('Doc history not found');

View File

@@ -1,6 +1,5 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { type Prisma, PrismaClient } from '@prisma/client';
import { Permission } from './types';

View File

@@ -16,9 +16,9 @@ import {
import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../fundamentals';
import {
CloudThrottlerGuard,
type FileUpload,
MakeCache,
PreventCache,
} from '../../../fundamentals';

View File

@@ -9,8 +9,10 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import {
PrismaClient,
type WorkspacePage as PrismaWorkspacePage,
} from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';

View File

@@ -20,10 +20,10 @@ import { getStreamAsBuffer } from 'get-stream';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
MailService,
MutexService,
Throttle,

View File

@@ -23,8 +23,6 @@ export async function collectMigrations(): Promise<Migration[]> {
)
.map(desc => join(folder, desc));
migrationFiles.sort((a, b) => a.localeCompare(b));
const migrations: Migration[] = await Promise.all(
migrationFiles.map(async file => {
return import(pathToFileURL(file).href).then(mod => {

View File

@@ -3,8 +3,9 @@ import '../prelude';
import { Logger } from '@nestjs/common';
import { CommandFactory } from 'nest-commander';
import { CliAppModule } from './app';
async function bootstrap() {
const { CliAppModule } = await import('./app');
await CommandFactory.run(CliAppModule, new Logger()).catch(e => {
console.error(e);
process.exit(1);

View File

@@ -4,7 +4,7 @@ import { PrismaClient } from '@prisma/client';
import { UserService } from '../../core/user';
import { Config, CryptoHelper } from '../../fundamentals';
export class SelfHostAdmin99999999 {
export class SelfHostAdmin1605053000403 {
// do the migration
static async up(_db: PrismaClient, ref: ModuleRef) {
const config = ref.get(Config, { strict: false });

View File

@@ -1,5 +1,4 @@
import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, type User } from '@prisma/client';
export class UnamedAccount1703756315970 {
// do the migration

View File

@@ -13,6 +13,12 @@ declare global {
}
}
export enum ExternalAccount {
github = 'github',
google = 'google',
firebase = 'firebase',
}
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
@@ -214,25 +220,6 @@ export interface AFFiNEConfig {
* authentication config
*/
auth: {
/**
* The minimum and maximum length of the password when registering new users
*
* @default [8,32]
*/
password: {
/**
* The minimum length of the password
*
* @default 8
*/
minLength: number;
/**
* The maximum length of the password
*
* @default 32
*/
maxLength: number;
};
session: {
/**
* Application auth expiration time in seconds
@@ -332,11 +319,6 @@ export interface AFFiNEConfig {
metrics: {
enabled: boolean;
};
telemetry: {
enabled: boolean;
token: string;
};
}
export * from './storage';

View File

@@ -5,8 +5,13 @@ import { createPrivateKey, createPublicKey } from 'node:crypto';
import { merge } from 'lodash-es';
import pkg from '../../../package.json' assert { type: 'json' };
import type { AFFINE_ENV, NODE_ENV, ServerFlavor } from './def';
import { AFFiNEConfig, DeploymentType } from './def';
import {
type AFFINE_ENV,
AFFiNEConfig,
DeploymentType,
type NODE_ENV,
type ServerFlavor,
} from './def';
import { readEnv } from './env';
import { getDefaultAFFiNEStorageConfig } from './storage';
@@ -20,10 +25,9 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
const ONE_DAY_IN_SEC = 60 * 60 * 24;
const keyPair = (function () {
const AFFINE_PRIVATE_KEY =
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
const privateKey = createPrivateKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
key: Buffer.from(AUTH_PRIVATE_KEY),
format: 'pem',
type: 'sec1',
})
@@ -33,7 +37,7 @@ const keyPair = (function () {
})
.toString('utf8');
const publicKey = createPublicKey({
key: Buffer.from(AFFINE_PRIVATE_KEY),
key: Buffer.from(AUTH_PRIVATE_KEY),
format: 'pem',
type: 'spki',
})
@@ -73,16 +77,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
Object.values(DeploymentType)
);
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
const affine = {
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
const node = {
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
const defaultConfig = {
serverId: 'affine-nestjs-server',
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
@@ -103,11 +98,19 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
ENV_MAP: {},
AFFINE_ENV,
get affine() {
return affine;
return {
canary: AFFINE_ENV === 'dev',
beta: AFFINE_ENV === 'beta',
stable: AFFINE_ENV === 'production',
};
},
NODE_ENV,
get node() {
return node;
return {
prod: NODE_ENV === 'production',
dev: NODE_ENV === 'development',
test: NODE_ENV === 'test',
};
},
get deploy() {
return !this.node.dev && !this.node.test;
@@ -147,10 +150,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
playground: true,
},
auth: {
password: {
minLength: node.prod ? 8 : 1,
maxLength: 32,
},
session: {
ttl: 15 * ONE_DAY_IN_SEC,
},
@@ -187,10 +186,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
metrics: {
enabled: false,
},
telemetry: {
enabled: isSelfhosted && !process.env.DISABLE_SERVER_TELEMETRY,
token: '389c0615a69b57cca7d3fa0a4824c930',
},
plugins: {
enabled: new Set(),
use(plugin, config) {

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { type Response } from 'express';
import { Config } from '../config';

View File

@@ -18,7 +18,13 @@ export type { GraphqlContext } from './graphql';
export { CryptoHelper, URLHelper } from './helpers';
export { MailService } from './mailer';
export { CallCounter, CallTimer, metrics } from './metrics';
export { type ILocker, Lock, Locker, MutexService } from './mutex';
export {
BucketService,
LockGuard,
MUTEX_RETRY,
MUTEX_WAIT,
MutexService,
} from './mutex';
export {
getOptionalModuleMetadata,
GlobalExceptionFilter,

View File

@@ -2,8 +2,7 @@ import { Inject, Injectable, Optional } from '@nestjs/common';
import { Config } from '../config';
import { URLHelper } from '../helpers';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
import { MAILER_SERVICE, type MailerService, type Options } from './mailer';
import { emailTemplate } from './template';
@Injectable()
export class MailService {

View File

@@ -43,9 +43,9 @@ const metricCreators: MetricCreators = {
gauge(meter: Meter, name: string, opts?: MetricOptions) {
let value: any;
let attrs: Attributes | undefined;
const ob$ = meter.createObservableGauge(name, opts);
const ob = meter.createObservableGauge(name, opts);
ob$.addCallback(result => {
ob.addCallback(result => {
result.observe(value, attrs);
});

View File

@@ -15,8 +15,11 @@ import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
import { Resource } from '@opentelemetry/resources';
import type { MeterProvider } from '@opentelemetry/sdk-metrics';
import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics';
import {
type MeterProvider,
MetricProducer,
MetricReader,
} from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import {
BatchSpanProcessor,

View File

@@ -0,0 +1,15 @@
export class BucketService {
private readonly bucket = new Map<string, string>();
get(key: string) {
return this.bucket.get(key);
}
set(key: string, value: string) {
this.bucket.set(key, value);
}
delete(key: string) {
this.bucket.delete(key);
}
}

View File

@@ -1,14 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { Locker } from './local-lock';
import { BucketService } from './bucket';
import { MutexService } from './mutex';
@Global()
@Module({
providers: [MutexService, Locker],
exports: [MutexService],
providers: [BucketService, MutexService],
exports: [BucketService, MutexService],
})
export class MutexModule {}
export { Locker, MutexService };
export { type Locker as ILocker, Lock } from './lock';
export { BucketService, MutexService };
export { LockGuard, MUTEX_RETRY, MUTEX_WAIT } from './mutex';

View File

@@ -1,28 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Cache } from '../cache';
import { Lock, Locker as ILocker } from './lock';
@Injectable()
export class Locker implements ILocker {
constructor(private readonly cache: Cache) {}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
const prevOwner = await this.cache.get<string>(lockKey);
if (prevOwner && prevOwner !== owner) {
throw new Error(`Lock for resource [${key}] has been holder by others`);
}
const acquired = await this.cache.set(lockKey, owner);
if (acquired) {
return new Lock(async () => {
await this.cache.delete(lockKey);
});
}
throw new Error(`Failed to acquire lock for resource [${key}]`);
}
}

View File

@@ -1,23 +0,0 @@
import { Logger } from '@nestjs/common';
import { retryable } from '../utils/promise';
export class Lock implements AsyncDisposable {
private readonly logger = new Logger(Lock.name);
constructor(private readonly dispose: () => Promise<void>) {}
async release() {
await retryable(() => this.dispose()).catch(e => {
this.logger.error('Failed to release lock', e);
});
}
async [Symbol.asyncDispose]() {
await this.release();
}
}
export interface Locker {
lock(owner: string, key: string): Promise<Lock>;
}

View File

@@ -1,12 +1,24 @@
import { randomUUID } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable, Logger, Scope } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { CONTEXT } from '@nestjs/graphql';
import type { GraphqlContext } from '../graphql';
import { retryable } from '../utils/promise';
import { Locker } from './local-lock';
import { BucketService } from './bucket';
export class LockGuard<M extends MutexService = MutexService>
implements AsyncDisposable
{
constructor(
private readonly mutex: M,
private readonly key: string
) {}
async [Symbol.asyncDispose]() {
return this.mutex.unlock(this.key);
}
}
export const MUTEX_RETRY = 5;
export const MUTEX_WAIT = 100;
@@ -14,21 +26,11 @@ export const MUTEX_WAIT = 100;
@Injectable({ scope: Scope.REQUEST })
export class MutexService {
protected logger = new Logger(MutexService.name);
private readonly locker: Locker;
constructor(
@Inject(CONTEXT) private readonly context: GraphqlContext,
private readonly ref: ModuleRef
) {
// nestjs will always find and injecting the locker from local module
// so the RedisLocker implemented by the plugin mechanism will not be able to overwrite the internal locker
// we need to use find and get the locker from the `ModuleRef` manually
//
// NOTE: when a `constructor` execute in normal service, the Locker module we expect may not have been initialized
// but in the Service with `Scope.REQUEST`, we will create a separate Service instance for each request
// at this time, all modules have been initialized, so we able to get the correct Locker instance in `constructor`
this.locker = this.ref.get(Locker, { strict: false });
}
private readonly bucket: BucketService
) {}
protected getId() {
let id = this.context.req.headers['x-transaction-id'] as string;
@@ -62,19 +64,33 @@ export class MutexService {
* @param key resource key
* @returns LockGuard
*/
async lock(key: string) {
try {
return await retryable(
() => this.locker.lock(this.getId(), key),
MUTEX_RETRY,
MUTEX_WAIT
);
} catch (e) {
this.logger.error(
`Failed to lock resource [${key}] after retry ${MUTEX_RETRY} times`,
e
);
return undefined;
async lock(key: string): Promise<LockGuard | undefined> {
const id = this.getId();
const fetchLock = async (retry: number): Promise<LockGuard | undefined> => {
if (retry === 0) {
this.logger.error(
`Failed to fetch lock ${key} after ${MUTEX_RETRY} retry`
);
return undefined;
}
const current = this.bucket.get(key);
if (current && current !== id) {
this.logger.warn(
`Failed to fetch lock ${key}, retrying in ${MUTEX_WAIT} ms`
);
await setTimeout(MUTEX_WAIT * (MUTEX_RETRY - retry + 1));
return fetchLock(retry - 1);
}
this.bucket.set(key, id);
return new LockGuard(this, key);
};
return fetchLock(MUTEX_RETRY);
}
async unlock(key: string): Promise<void> {
if (this.bucket.get(key) === this.getId()) {
this.bucket.delete(key);
}
}
}

View File

@@ -6,13 +6,7 @@ import { PrismaService } from './service';
// only `PrismaClient` can be injected
const clientProvider: Provider = {
provide: PrismaClient,
useFactory: () => {
if (PrismaService.INSTANCE) {
return PrismaService.INSTANCE;
}
return new PrismaService();
},
useClass: PrismaService,
};
@Global()

View File

@@ -19,9 +19,6 @@ export class PrismaService
}
async onModuleDestroy(): Promise<void> {
if (!AFFiNE.node.test) {
await this.$disconnect();
PrismaService.INSTANCE = null;
}
await this.$disconnect();
}
}

View File

@@ -1,4 +1,5 @@
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import {
Throttle,
ThrottlerGuard,

View File

@@ -1,44 +0,0 @@
import { defer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
constructor(
executor: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (reason?: any) => void
) => void,
retryTimes: number = 3,
retryIntervalInMs: number = 300
) {
super((resolve, reject) => {
defer(() => new Promise<T>(executor))
.pipe(
retry({
count: retryTimes,
delay: retryIntervalInMs,
})
)
.subscribe({
next: v => {
resolve(v);
},
error: e => {
reject(e);
},
});
});
}
}
export function retryable<Ret = unknown>(
asyncFn: () => Promise<Ret>,
retryTimes = 3,
retryIntervalInMs = 300
): Promise<Ret> {
return new RetryablePromise<Ret>(
(resolve, reject) => {
asyncFn().then(resolve).catch(reject);
},
retryTimes,
retryIntervalInMs
);
}

View File

@@ -40,7 +40,7 @@ export class OAuthController {
const provider = this.providerFactory.get(providerName);
if (!provider) {
throw new BadRequestException('Invalid OAuth provider');
throw new BadRequestException('Invalid provider');
}
const state = await this.oauth.saveOAuthState({

View File

@@ -36,7 +36,7 @@ export class GoogleOAuthProvider extends AutoRegisteredOAuthProvider {
redirect_uri: this.url.link('/oauth/callback'),
response_type: 'code',
scope: 'openid email profile',
prompt: 'select_account',
promot: 'select_account',
access_type: 'offline',
...this.config.args,
state,

View File

@@ -16,7 +16,7 @@ export function registerOAuthProvider(
@Injectable()
export class OAuthProviderFactory {
get providers() {
return Array.from(PROVIDERS.keys());
return PROVIDERS.keys();
}
get(name: OAuthProviderName): OAuthProvider | undefined {

View File

@@ -1,4 +1,8 @@
import { BadGatewayException, ForbiddenException } from '@nestjs/common';
import {
BadGatewayException,
ForbiddenException,
InternalServerErrorException,
} from '@nestjs/common';
import {
Args,
Context,
@@ -44,11 +48,11 @@ class SubscriptionPrice {
@Field()
currency!: string;
@Field(() => Int, { nullable: true })
amount?: number | null;
@Field()
amount!: number;
@Field(() => Int, { nullable: true })
yearlyAmount?: number | null;
@Field()
yearlyAmount!: number;
}
@ObjectType('UserSubscription')
@@ -172,39 +176,64 @@ export class SubscriptionResolver {
}
);
function findPrice(plan: SubscriptionPlan) {
const prices = group[plan];
return Object.entries(group).map(([plan, prices]) => {
const yearly = prices.find(
price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Yearly
);
const monthly = prices.find(
price =>
decodeLookupKey(
// @ts-expect-error empty lookup key is filtered out
price.lookup_key
)[1] === SubscriptionRecurring.Monthly
);
if (!prices) {
return null;
if (!yearly || !monthly) {
throw new InternalServerErrorException(
'The prices are not configured correctly.'
);
}
const monthlyPrice = prices.find(p => p.recurring?.interval === 'month');
const yearlyPrice = prices.find(p => p.recurring?.interval === 'year');
const currency = monthlyPrice?.currency ?? yearlyPrice?.currency ?? 'usd';
return {
currency,
amount: monthlyPrice?.unit_amount,
yearlyAmount: yearlyPrice?.unit_amount,
type: 'fixed',
plan: plan as SubscriptionPlan,
currency: monthly.currency,
amount: monthly.unit_amount ?? 0,
yearlyAmount: yearly.unit_amount ?? 0,
};
});
}
/**
* @deprecated
*/
@Mutation(() => String, {
deprecationReason: 'use `createCheckoutSession` instead',
description: 'Create a subscription checkout link of stripe',
})
async checkout(
@CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Args('idempotencyKey') idempotencyKey: string
) {
const session = await this.service.createCheckoutSession({
user,
plan: SubscriptionPlan.Pro,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
idempotencyKey,
});
if (!session.url) {
throw new BadGatewayException('Failed to create checkout session.');
}
// extend it when new plans are added
const fixedPlans = [SubscriptionPlan.Pro, SubscriptionPlan.AI];
return fixedPlans.reduce((prices, plan) => {
const price = findPrice(plan);
if (price && (price.amount || price.yearlyAmount)) {
prices.push({
type: 'fixed',
plan,
...price,
});
}
return prices;
}, [] as SubscriptionPrice[]);
return session.url;
}
@Mutation(() => String, {
@@ -242,35 +271,17 @@ export class SubscriptionResolver {
@Mutation(() => UserSubscriptionType)
async cancelSubscription(
@CurrentUser() user: CurrentUser,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.cancelSubscription(idempotencyKey, user.id, plan);
return this.service.cancelSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
async resumeSubscription(
@CurrentUser() user: CurrentUser,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.resumeCanceledSubscription(
idempotencyKey,
user.id,
plan
);
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
@@ -278,19 +289,11 @@ export class SubscriptionResolver {
@CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan,
@Args('idempotencyKey') idempotencyKey: string
) {
return this.service.updateSubscriptionRecurring(
idempotencyKey,
user.id,
plan,
recurring
);
}
@@ -303,21 +306,11 @@ export class UserSubscriptionResolver {
private readonly db: PrismaClient
) {}
@ResolveField(() => UserSubscriptionType, {
nullable: true,
deprecationReason: 'use `UserType.subscriptions`',
})
@ResolveField(() => UserSubscriptionType, { nullable: true })
async subscription(
@Context() ctx: { isAdminQuery: boolean },
@CurrentUser() me: User,
@Parent() user: User,
@Args({
name: 'plan',
type: () => SubscriptionPlan,
nullable: true,
defaultValue: SubscriptionPlan.Pro,
})
plan: SubscriptionPlan
@Parent() user: User
) {
// allow admin to query other user's subscription
if (!ctx.isAdminQuery && me.id !== user.id) {
@@ -346,30 +339,9 @@ export class UserSubscriptionResolver {
}
return this.db.userSubscription.findUnique({
where: {
userId_plan: {
userId: user.id,
plan,
},
status: SubscriptionStatus.Active,
},
});
}
@ResolveField(() => [UserSubscriptionType])
async subscriptions(
@CurrentUser() me: User,
@Parent() user: User
): Promise<UserSubscription[]> {
if (me.id !== user.id) {
throw new ForbiddenException(
'You are not allowed to access this subscription.'
);
}
return this.db.userSubscription.findMany({
where: {
userId: user.id,
status: SubscriptionStatus.Active,
},
});
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
import type {
Prisma,
@@ -65,9 +65,7 @@ export class SubscriptionService {
) {}
async listPrices() {
return this.stripe.prices.list({
active: true,
});
return this.stripe.prices.list();
}
async createCheckoutSession({
@@ -88,15 +86,12 @@ export class SubscriptionService {
const currentSubscription = await this.db.userSubscription.findFirst({
where: {
userId: user.id,
plan,
status: SubscriptionStatus.Active,
},
});
if (currentSubscription) {
throw new BadRequestException(
`You've already subscripted to the ${plan} plan`
);
throw new Error('You already have a subscription');
}
const price = await this.getPrice(plan, recurring);
@@ -157,47 +152,35 @@ export class SubscriptionService {
async cancelSubscription(
idempotencyKey: string,
userId: string,
plan: SubscriptionPlan
userId: string
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
include: {
subscriptions: {
where: {
plan,
},
},
subscription: true,
},
});
if (!user) {
throw new BadRequestException('Unknown user');
if (!user?.subscription) {
throw new Error('You do not have any subscription');
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
}
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled'
);
if (user.subscription.canceledAt) {
throw new Error('Your subscription has already been canceled');
}
// should release the schedule first
if (subscriptionInDB.stripeScheduleId) {
if (user.subscription.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule(
subscriptionInDB.stripeScheduleId
user.subscription.stripeScheduleId
);
await manager.cancel(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
subscriptionInDB.stripeSubscriptionId
user.subscription.stripeSubscriptionId
),
false
);
@@ -205,7 +188,7 @@ export class SubscriptionService {
// let customer contact support if they want to cancel immediately
// see https://stripe.com/docs/billing/subscriptions/cancel
const subscription = await this.stripe.subscriptions.update(
subscriptionInDB.stripeSubscriptionId,
user.subscription.stripeSubscriptionId,
{ cancel_at_period_end: true },
{ idempotencyKey }
);
@@ -215,52 +198,44 @@ export class SubscriptionService {
async resumeCanceledSubscription(
idempotencyKey: string,
userId: string,
plan: SubscriptionPlan
userId: string
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
include: {
subscriptions: true,
subscription: true,
},
});
if (!user) {
throw new BadRequestException('Unknown user');
if (!user?.subscription) {
throw new Error('You do not have any subscription');
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
if (!user.subscription.canceledAt) {
throw new Error('Your subscription has not been canceled');
}
if (!subscriptionInDB.canceledAt) {
throw new BadRequestException('Your subscription has not been canceled');
if (user.subscription.end < new Date()) {
throw new Error('Your subscription is expired, please checkout again.');
}
if (subscriptionInDB.end < new Date()) {
throw new BadRequestException(
'Your subscription is expired, please checkout again.'
);
}
if (subscriptionInDB.stripeScheduleId) {
if (user.subscription.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule(
subscriptionInDB.stripeScheduleId
user.subscription.stripeScheduleId
);
await manager.resume(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
subscriptionInDB.stripeSubscriptionId
user.subscription.stripeSubscriptionId
),
false
);
} else {
const subscription = await this.stripe.subscriptions.update(
subscriptionInDB.stripeSubscriptionId,
user.subscription.stripeSubscriptionId,
{ cancel_at_period_end: false },
{ idempotencyKey }
);
@@ -272,7 +247,6 @@ export class SubscriptionService {
async updateSubscriptionRecurring(
idempotencyKey: string,
userId: string,
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
): Promise<UserSubscription> {
const user = await this.db.user.findUnique({
@@ -280,38 +254,30 @@ export class SubscriptionService {
id: userId,
},
include: {
subscriptions: true,
subscription: true,
},
});
if (!user) {
throw new BadRequestException('Unknown user');
}
const subscriptionInDB = user?.subscriptions.find(s => s.plan === plan);
if (!subscriptionInDB) {
throw new BadRequestException(`You didn't subscript to the ${plan} plan`);
if (!user?.subscription) {
throw new Error('You do not have any subscription');
}
if (subscriptionInDB.canceledAt) {
throw new BadRequestException(
'Your subscription has already been canceled '
);
if (user.subscription.canceledAt) {
throw new Error('Your subscription has already been canceled ');
}
if (subscriptionInDB.recurring === recurring) {
throw new BadRequestException(
`You are already in ${recurring} recurring`
);
if (user.subscription.recurring === recurring) {
throw new Error('You have already subscribed to this plan');
}
const price = await this.getPrice(
subscriptionInDB.plan as SubscriptionPlan,
user.subscription.plan as SubscriptionPlan,
recurring
);
const manager = await this.scheduleManager.fromSubscription(
`${idempotencyKey}-fromSubscription`,
subscriptionInDB.stripeSubscriptionId
user.subscription.stripeSubscriptionId
);
await manager.update(
@@ -327,7 +293,7 @@ export class SubscriptionService {
return await this.db.userSubscription.update({
where: {
id: subscriptionInDB.id,
id: user.subscription.id,
},
data: {
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
@@ -344,7 +310,7 @@ export class SubscriptionService {
});
if (!user) {
throw new BadRequestException('Unknown user');
throw new Error('Unknown user');
}
try {
@@ -355,7 +321,7 @@ export class SubscriptionService {
return portal.url;
} catch (e) {
this.logger.error('Failed to create customer portal.', e);
throw new BadRequestException('Failed to create customer portal');
throw new Error('Failed to create customer portal');
}
}
@@ -552,10 +518,7 @@ export class SubscriptionService {
const currentSubscription = await this.db.userSubscription.findUnique({
where: {
userId_plan: {
userId: user.id,
plan,
},
userId: user.id,
},
});
@@ -678,8 +641,8 @@ export class SubscriptionService {
});
if (!prices.data.length) {
throw new BadRequestException(
`Unknown subscription plan ${plan} with ${recurring} recurring`
throw new Error(
`Unknown subscription plan ${plan} with recurring ${recurring}`
);
}

View File

@@ -1,5 +1,5 @@
import type { User } from '@prisma/client';
import type { Stripe } from 'stripe';
import { type User } from '@prisma/client';
import { type Stripe } from 'stripe';
import type { Payload } from '../../fundamentals/event/def';
@@ -20,7 +20,6 @@ export enum SubscriptionRecurring {
export enum SubscriptionPlan {
Free = 'free',
Pro = 'pro',
AI = 'ai',
Team = 'team',
Enterprise = 'enterprise',
SelfHosted = 'selfhosted',

View File

@@ -12,7 +12,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import type { Request } from 'express';
import Stripe from 'stripe';
import { Public } from '../../core/auth';
import { Config } from '../../fundamentals';
@Controller('/api/stripe')
@@ -29,7 +28,6 @@ export class StripeWebhook {
this.webhookKey = config.plugins.payment.stripe.keys.webhookKey;
}
@Public()
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
// Check if webhook signing is configured.

View File

@@ -1,15 +1,27 @@
import { Global, Provider, Type } from '@nestjs/common';
import type { RedisOptions } from 'ioredis';
import { Redis } from 'ioredis';
import { CONTEXT } from '@nestjs/graphql';
import { Redis, type RedisOptions } from 'ioredis';
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
import { Cache, Locker, SessionCache } from '../../fundamentals';
import {
BucketService,
Cache,
type GraphqlContext,
MutexService,
SessionCache,
} from '../../fundamentals';
import { ThrottlerStorage } from '../../fundamentals/throttler';
import { SocketIoAdapterImpl } from '../../fundamentals/websocket';
import { Plugin } from '../registry';
import { RedisCache } from './cache';
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
import { RedisMutexLocker } from './mutex';
import {
CacheRedis,
MutexRedis,
SessionRedis,
SocketIoRedis,
ThrottlerRedis,
} from './instances';
import { MutexRedisService } from './mutex';
import { createSockerIoAdapterImpl } from './ws-adapter';
function makeProvider(token: Type, impl: Type<Redis>): Provider {
@@ -32,7 +44,7 @@ const throttlerStorageProvider: Provider = {
useFactory: (redis: Redis) => {
return new ThrottlerStorageRedisService(redis);
},
inject: [SessionRedis],
inject: [ThrottlerRedis],
};
// socket io
@@ -46,14 +58,23 @@ const socketIoRedisAdapterProvider: Provider = {
// mutex
const mutexRedisAdapterProvider: Provider = {
provide: Locker,
useClass: RedisMutexLocker,
provide: MutexService,
useFactory: (redis: Redis, ctx: GraphqlContext, bucket: BucketService) => {
return new MutexRedisService(redis, ctx, bucket);
},
inject: [MutexRedis, CONTEXT, BucketService],
};
@Global()
@Plugin({
name: 'redis',
providers: [CacheRedis, SessionRedis, SocketIoRedis],
providers: [
CacheRedis,
SessionRedis,
ThrottlerRedis,
SocketIoRedis,
MutexRedis,
],
overrides: [
cacheProvider,
sessionCacheProvider,

View File

@@ -34,6 +34,13 @@ export class CacheRedis extends Redis {
}
}
@Injectable()
export class ThrottlerRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 1 });
}
}
@Injectable()
export class SessionRedis extends Redis {
constructor(config: Config) {
@@ -47,3 +54,10 @@ export class SocketIoRedis extends Redis {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 3 });
}
}
@Injectable()
export class MutexRedis extends Redis {
constructor(config: Config) {
super({ ...config.plugins.redis, db: (config.plugins.redis?.db ?? 0) + 4 });
}
}

View File

@@ -1,65 +1,96 @@
import { setTimeout } from 'node:timers/promises';
import { Injectable, Logger } from '@nestjs/common';
import { Command } from 'ioredis';
import Redis, { Command } from 'ioredis';
import { ILocker, Lock } from '../../fundamentals';
import { SessionRedis } from './instances';
import {
BucketService,
type GraphqlContext,
LockGuard,
MUTEX_RETRY,
MUTEX_WAIT,
MutexService,
} from '../../fundamentals';
// === atomic mutex lock ===
// acquire lock
// return 1 if lock is acquired
// return 0 if lock is not acquired
const lockScript = `local key = KEYS[1]
local owner = ARGV[1]
local clientId = ARGV[1]
local releaseTime = ARGV[2]
-- if lock is not exists or lock is owned by the owner
-- then set lock to the owner and return 1, otherwise return 0
-- if the lock is not released correctly due to unexpected reasons
-- lock will be released after 60 seconds
if redis.call("get", key) == owner or redis.call("set", key, owner, "NX", "EX", 60) then
if redis.call("get", key) == clientId or redis.call("set", key, clientId, "NX", "PX", releaseTime) then
return 1
else
return 0
end`;
// release lock
// return 1 if lock is released or lock is not exists
// return 0 if lock is not owned by the owner
const unlockScript = `local key = KEYS[1]
local owner = ARGV[1]
local clientId = ARGV[1]
local value = redis.call("get", key)
if value == owner then
if redis.call("get", key) == clientId then
return redis.call("del", key)
elseif value == nil then
return 1
else
return 0
end`;
@Injectable()
export class RedisMutexLocker implements ILocker {
private readonly logger = new Logger(RedisMutexLocker.name);
constructor(private readonly redis: SessionRedis) {}
export class MutexRedisService extends MutexService {
constructor(
private readonly redis: Redis,
context: GraphqlContext,
bucket: BucketService
) {
super(context, bucket);
this.logger = new Logger(MutexRedisService.name);
}
async lock(owner: string, key: string): Promise<Lock> {
const lockKey = `MutexLock:${key}`;
this.logger.debug(`Client ${owner} is trying to lock resource ${key}`);
override async lock(
key: string,
releaseTimeInMS: number = 200
): Promise<LockGuard | undefined> {
const clientId = this.getId();
this.logger.debug(`Client ${clientId} lock try to lock ${key}`);
const releaseTime = releaseTimeInMS.toString();
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', lockKey, owner])
);
if (success === 1) {
return new Lock(async () => {
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', lockKey, owner])
const fetchLock = async (retry: number): Promise<LockGuard | undefined> => {
if (retry === 0) {
this.logger.error(
`Failed to fetch lock ${key} after ${MUTEX_RETRY} retry`
);
if (result === 0) {
throw new Error(`Failed to release lock ${key}`);
return undefined;
}
try {
const success = await this.redis.sendCommand(
new Command('EVAL', [lockScript, '1', key, clientId, releaseTime])
);
if (success === 1) {
return new LockGuard(this, key);
} else {
this.logger.warn(
`Failed to fetch lock ${key}, retrying in ${MUTEX_WAIT} ms`
);
await setTimeout(MUTEX_WAIT * (MUTEX_RETRY - retry + 1));
return fetchLock(retry - 1);
}
});
}
} catch (error: any) {
this.logger.error(
`Unexpected error when fetch lock ${key}: ${error.message}`
);
return undefined;
}
};
throw new Error(`Failed to acquire lock for resource [${key}]`);
return fetchLock(MUTEX_RETRY);
}
override async unlock(key: string, ignoreUnlockFail = false): Promise<void> {
const clientId = this.getId();
const result = await this.redis.sendCommand(
new Command('EVAL', [unlockScript, '1', key, clientId])
);
if (result === 0) {
if (!ignoreUnlockFail) {
throw new Error(`Failed to release lock ${key}`);
} else {
this.logger.warn(`Failed to release lock ${key}`);
}
}
}
}

View File

@@ -10,10 +10,6 @@ input CreateCheckoutSessionInput {
successCallbackLink: String
}
type CredentialsRequirementType {
password: PasswordLimitsType!
}
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
@@ -114,10 +110,13 @@ type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addToEarlyAccess(email: String!): Int!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
cancelSubscription(idempotencyKey: String!): UserSubscription!
changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!): UserType!
"""Create a subscription checkout link of stripe"""
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
"""Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
@@ -138,7 +137,7 @@ type Mutation {
removeAvatar: RemoveAvatar!
removeEarlyAccess(email: String!): Int!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
resumeSubscription(idempotencyKey: String!): UserSubscription!
revoke(userId: String!, workspaceId: String!): Boolean!
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
@@ -153,7 +152,7 @@ type Mutation {
signIn(email: String!, password: String!): UserType!
signUp(email: String!, name: String!, password: String!): UserType!
updateProfile(input: UpdateUserInput!): UserType!
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription!
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
@@ -168,11 +167,6 @@ enum OAuthProviderType {
Google
}
type PasswordLimitsType {
maxLength: Int!
minLength: Int!
}
"""User permission in workspace"""
enum Permission {
Admin
@@ -245,9 +239,6 @@ type ServerConfigType {
"""server base url"""
baseUrl: String!
"""credentials requirement"""
credentialsRequirement: CredentialsRequirementType!
"""enabled server features"""
features: [ServerFeature!]!
@@ -276,7 +267,6 @@ enum ServerFeature {
}
enum SubscriptionPlan {
AI
Enterprise
Free
Pro
@@ -285,11 +275,11 @@ enum SubscriptionPlan {
}
type SubscriptionPrice {
amount: Int
amount: Int!
currency: String!
plan: SubscriptionPlan!
type: String!
yearlyAmount: Int
yearlyAmount: Int!
}
enum SubscriptionRecurring {
@@ -398,8 +388,7 @@ type UserType {
"""User name"""
name: String!
quota: UserQuota
subscription(plan: SubscriptionPlan = Pro): UserSubscription @deprecated(reason: "use `UserType.subscriptions`")
subscriptions: [UserSubscription!]!
subscription: UserSubscription
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
}

View File

@@ -1,6 +1,5 @@
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import request from 'supertest';
import { AppModule } from '../src/app.module';

View File

@@ -3,8 +3,7 @@ import {
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import { AuthService } from '../src/core/auth/service';
import { MailService } from '../src/fundamentals/mailer';

View File

@@ -1,164 +0,0 @@
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AuthModule, CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { MailService } from '../../src/fundamentals';
import { createTestingApp, getSession, sessionCookie } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.createStubInstance(MailService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to sign in with credential', async t => {
const { app, u1 } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const session = await getSession(app, res);
t.is(session.user!.id, u1.id);
});
test('should be able to sign in with email', async t => {
const { app, u1, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
let [signInLink] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
signInLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signInLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.id, u1.id);
});
test('should be able to sign up with email', async t => {
const { app, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignUpMail.resolves({ rejected: [] });
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: 'u2@affine.pro' })
.expect(200);
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
let [signUpLink] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
signUpLink = url.pathname + url.search;
const signInRes = await request(app.getHttpServer())
.get(signUpLink)
.expect(302);
const session = await getSession(app, signInRes);
t.is(session.user!.email, 'u2@affine.pro');
});
test('should not be able to sign in if email is invalid', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: '' })
.expect(400);
t.is(res.body.message, 'Invalid email address');
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth, u1, mailer } = t.context;
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email })
.expect(HttpStatus.PAYMENT_REQUIRED);
t.true(mailer.sendSignInMail.notCalled);
canSignInStub.restore();
});
test('should be able to sign out', async t => {
const { app, u1 } = t.context;
const signInRes = await request(app.getHttpServer())
.post('/api/auth/sign-in')
.send({ email: u1.email, password: '1' })
.expect(200);
const cookie = sessionCookie(signInRes.headers);
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.set('cookie', cookie)
.expect(200);
const session = await getSession(app, signInRes);
t.falsy(session.user);
});
test('should not be able to sign out if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/api/auth/sign-out')
.expect(HttpStatus.UNAUTHORIZED);
t.assert(true);
});

View File

@@ -1,131 +0,0 @@
import { Controller, Get, HttpStatus, INestApplication } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import {
AuthGuard,
AuthModule,
CurrentUser,
Public,
} from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { createTestingApp } from '../utils';
@Controller('/')
class TestController {
@Public()
@Get('/public')
home(@CurrentUser() user?: CurrentUser) {
return { user };
}
@Get('/private')
private(@CurrentUser() user: CurrentUser) {
return { user };
}
}
const test = ava as TestFn<{
app: INestApplication;
auth: Sinon.SinonStubbedInstance<AuthService>;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [AuthModule],
providers: [
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
controllers: [TestController],
tapModule: m => {
m.overrideProvider(AuthService).useValue(
Sinon.createStubInstance(AuthService)
);
},
});
t.context.auth = app.get(AuthService);
t.context.app = app;
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test('should be able to visit public api if not signed in', async t => {
const { app } = t.context;
const res = await request(app.getHttpServer()).get('/public').expect(200);
t.is(res.body.user, undefined);
});
test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/public')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should not be able to visit private api if not signed in', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/private')
.expect(HttpStatus.UNAUTHORIZED)
.expect({
statusCode: 401,
message: 'You are not signed in.',
error: 'Unauthorized',
});
t.assert(true);
});
test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ id: '1' });
const res = await request(app.getHttpServer())
.get('/private')
.set('Cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.OK);
t.is(res.body.user.id, '1');
});
test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
await request(app.getHttpServer())
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
});

View File

@@ -1,219 +0,0 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { CurrentUser } from '../../src/core/auth';
import { AuthService, parseAuthUserSeqNum } from '../../src/core/auth/service';
import { FeatureModule } from '../../src/core/features';
import { UserModule, UserService } from '../../src/core/user';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
imports: [FeatureModule, UserModule],
providers: [AuthService],
});
t.context.auth = m.get(AuthService);
t.context.user = m.get(UserService);
t.context.db = m.get(PrismaClient);
t.context.m = m;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to parse auth user seq num', t => {
t.deepEqual(
[
'1',
'2',
3,
-3,
'-4',
'1.1',
'str',
'1111111111111111111111111111111111111111111',
].map(parseAuthUserSeqNum),
[1, 2, 3, 0, 0, 0, 0, 0]
);
});
test('should be able to sign up', async t => {
const { auth } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
t.is(u2.email, 'u2@affine.pro');
const signedU2 = await auth.signIn(u2.email, '1');
t.is(u2.email, signedU2.email);
});
test('should throw if email duplicated', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signUp('u1', 'u1@affine.pro', '1'), {
message: 'Email was taken',
});
});
test('should be able to sign in', async t => {
const { auth } = t.context;
const signedInUser = await auth.signIn('u1@affine.pro', '1');
t.is(signedInUser.email, 'u1@affine.pro');
});
test('should throw if user not found', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'Invalid sign in credentials',
});
});
test('should throw if password not set', async t => {
const { user, auth } = t.context;
await user.createUser({
email: 'u2@affine.pro',
name: 'u2',
});
await t.throwsAsync(() => auth.signIn('u2@affine.pro', '1'), {
message: 'User Password is not set. Should login through email link.',
});
});
test('should throw if password not match', async t => {
const { auth } = t.context;
await t.throwsAsync(() => auth.signIn('u1@affine.pro', '2'), {
message: 'Invalid sign in credentials',
});
});
test('should be able to change password', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changePassword(u1.id, '2');
await t.throwsAsync(
() => auth.signIn('u1@affine.pro', '1' /* old password */),
{
message: 'Invalid sign in credentials',
}
);
signedInU1 = await auth.signIn('u1@affine.pro', '2');
t.is(signedInU1.email, u1.email);
});
test('should be able to change email', async t => {
const { auth, u1 } = t.context;
let signedInU1 = await auth.signIn('u1@affine.pro', '1');
t.is(signedInU1.email, u1.email);
await auth.changeEmail(u1.id, 'u2@affine.pro');
await t.throwsAsync(() => auth.signIn('u1@affine.pro' /* old email */, '1'), {
message: 'Invalid sign in credentials',
});
signedInU1 = await auth.signIn('u2@affine.pro', '1');
t.is(signedInU1.email, 'u2@affine.pro');
});
// Tests for Session
test('should be able to create user session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
t.is(session.userId, u1.id);
});
test('should be able to get user from session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const user = await auth.getUser(session.sessionId);
t.not(user, null);
t.is(user!.id, u1.id);
});
test('should be able to sign out session', async t => {
const { auth, u1 } = t.context;
const session = await auth.createUserSession(u1);
const signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
});
// Tests for Multi-Accounts Session
test('should be able to sign in different user in a same session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
const [signedU1, signedU2] = await auth.getUserList(session.sessionId);
t.not(signedU1, null);
t.not(signedU2, null);
t.is(signedU1!.id, u1.id);
t.is(signedU2!.id, u2.id);
});
test('should be able to signout multi accounts session', async t => {
const { auth, u1 } = t.context;
const u2 = await auth.signUp('u2', 'u2@affine.pro', '1');
const session = await auth.createUserSession(u1);
await auth.createUserSession(u2, session.sessionId);
// sign out user at seq(0)
let signedOutSession = await auth.signOut(session.sessionId);
t.not(signedOutSession, null);
const signedU2 = await auth.getUser(session.sessionId, 0);
const noUser = await auth.getUser(session.sessionId, 1);
t.is(noUser, null);
t.not(signedU2, null);
t.is(signedU2!.id, u2.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
const noUser2 = await auth.getUser(session.sessionId, 0);
t.is(noUser2, null);
});

View File

@@ -1,93 +0,0 @@
import { TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { TokenService, TokenType } from '../../src/core/auth';
import { createTestingModule } from '../utils';
const test = ava as TestFn<{
ts: TokenService;
m: TestingModule;
}>;
test.beforeEach(async t => {
const m = await createTestingModule({
providers: [TokenService],
});
t.context.ts = m.get(TokenService);
t.context.m = m;
});
test.afterEach.always(async t => {
await t.context.m.close();
});
test('should be able to create token', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should fail the verification if the token is invalid', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
// wrong type
t.falsy(
await ts.verifyToken(TokenType.ChangeEmail, token, {
credential: 'user@affine.pro',
})
);
// no credential
t.falsy(await ts.verifyToken(TokenType.SignIn, token));
// wrong credential
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'wrong@affine.pro',
})
);
});
test('should fail if the token expired', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
await t.context.m.get(PrismaClient).verificationToken.updateMany({
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});
test('should be able to verify only once', async t => {
const { ts } = t.context;
const token = await ts.createToken(TokenType.SignIn, 'user@affine.pro');
t.truthy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
// will be invalid after the first time of verification
t.falsy(
await ts.verifyToken(TokenType.SignIn, token, {
credential: 'user@affine.pro',
})
);
});

View File

@@ -127,7 +127,7 @@ test('should merge update when intervel due', async t => {
await manager.autoSquash();
t.deepEqual(
(await manager.getBinary(ws.id, '1'))?.binary.toString('hex'),
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
Buffer.from(update.buffer).toString('hex')
);
@@ -150,7 +150,7 @@ test('should merge update when intervel due', async t => {
await manager.autoSquash();
t.deepEqual(
(await manager.getBinary(ws.id, '1'))?.binary.toString('hex'),
(await manager.getBinary(ws.id, '1'))?.toString('hex'),
Buffer.from(encodeStateAsUpdate(doc)).toString('hex')
);
});
@@ -275,21 +275,20 @@ test('should throw if meet max retry times', async t => {
test('should be able to insert the snapshot if it is new created', async t => {
const manager = m.get(DocManager);
{
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
await manager.push('1', '1', Buffer.from(update));
await manager.push('1', '1', Buffer.from(update));
}
const updates = await manager.getUpdates('1', '1');
t.is(updates.length, 1);
// @ts-expect-error private
const { doc } = await manager.squash(null, updates);
const snapshot = await manager.squash(null, updates);
t.truthy(doc);
t.is(doc.getText('content').toString(), 'hello');
t.truthy(snapshot);
t.is(snapshot.getText('content').toString(), 'hello');
const restUpdates = await manager.getUpdates('1', '1');
@@ -316,14 +315,14 @@ test('should be able to merge updates into snapshot', async t => {
{
await manager.batchPush('1', '1', updates.slice(0, 2));
// do the merge
const { doc } = (await manager.get('1', '1'))!;
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'helloworld');
}
{
await manager.batchPush('1', '1', updates.slice(2));
const { doc } = (await manager.get('1', '1'))!;
const doc = (await manager.get('1', '1'))!;
t.is(doc.getText('content').toString(), 'hello world!');
}
@@ -373,7 +372,7 @@ test('should not update snapshot if doc is outdated', async t => {
const updateRecords = await manager.getUpdates('2', '1');
// @ts-expect-error private
const { doc } = await manager.squash(snapshot, updateRecords);
const doc = await manager.squash(snapshot, updateRecords);
// all updated will merged into doc not matter it's timestamp is outdated or not,
// but the snapshot record will not be updated

View File

@@ -2,8 +2,7 @@
import { INestApplication, Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import { AuthService } from '../src/core/auth/service';
import {

View File

@@ -7,7 +7,7 @@ import * as Sinon from 'sinon';
import { DocHistoryManager } from '../src/core/doc';
import { QuotaModule } from '../src/core/quota';
import { StorageModule } from '../src/core/storage';
import type { EventPayload } from '../src/fundamentals/event';
import { type EventPayload } from '../src/fundamentals/event';
import { createTestingModule } from './utils';
let m: TestingModule;

View File

@@ -7,8 +7,7 @@ import {
getLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import { AuthService } from '../src/core/auth/service';
import { ConfigModule } from '../src/fundamentals/config';

View File

@@ -1,6 +1,5 @@
import type { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../src/app.module';

View File

@@ -1,345 +0,0 @@
import '../../src/plugins/config';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import request from 'supertest';
import { AppModule } from '../../src/app.module';
import { CurrentUser } from '../../src/core/auth';
import { AuthService } from '../../src/core/auth/service';
import { UserService } from '../../src/core/user';
import { Config, ConfigModule } from '../../src/fundamentals/config';
import { GoogleOAuthProvider } from '../../src/plugins/oauth/providers/google';
import { OAuthService } from '../../src/plugins/oauth/service';
import { OAuthProviderName } from '../../src/plugins/oauth/types';
import { createTestingApp, getSession } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
oauth: OAuthService;
user: UserService;
u1: CurrentUser;
db: PrismaClient;
app: INestApplication;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
plugins: {
oauth: {
providers: {
google: {
clientId: 'google-client-id',
clientSecret: 'google-client-secret',
},
},
},
},
}),
AppModule,
],
});
t.context.auth = app.get(AuthService);
t.context.oauth = app.get(OAuthService);
t.context.user = app.get(UserService);
t.context.db = app.get(PrismaClient);
t.context.app = app;
t.context.u1 = await t.context.auth.signUp('u1', 'u1@affine.pro', '1');
});
test.afterEach.always(async t => {
await t.context.app.close();
});
test("should be able to redirect to oauth provider's login page", async t => {
const { app } = t.context;
const res = await request(app.getHttpServer())
.get('/oauth/login?provider=Google')
.expect(HttpStatus.FOUND);
const redirect = new URL(res.header.location);
t.is(redirect.origin, 'https://accounts.google.com');
t.is(redirect.pathname, '/o/oauth2/v2/auth');
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
t.is(
redirect.searchParams.get('redirect_uri'),
app.get(Config).baseUrl + '/oauth/callback'
);
t.is(redirect.searchParams.get('response_type'), 'code');
t.is(redirect.searchParams.get('prompt'), 'select_account');
t.truthy(redirect.searchParams.get('state'));
});
test('should throw if provider is invalid', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/login?provider=Invalid')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid OAuth provider',
error: 'Bad Request',
});
t.assert(true);
});
test('should be able to save oauth state', async t => {
const { oauth } = t.context;
const id = await oauth.saveOAuthState({
redirectUri: 'https://example.com',
provider: OAuthProviderName.Google,
});
const state = await oauth.getOAuthState(id);
t.truthy(state);
t.is(state!.provider, OAuthProviderName.Google);
t.is(state!.redirectUri, 'https://example.com');
});
test('should be able to get registered oauth providers', async t => {
const { oauth } = t.context;
const providers = oauth.availableOAuthProviders();
t.deepEqual(providers, [OAuthProviderName.Google]);
});
test('should throw if code is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing query parameter `code`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is missing in callback uri', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid callback state parameter',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if state is expired', async t => {
const { app } = t.context;
await request(app.getHttpServer())
.get('/oauth/callback?code=1&state=1')
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'OAuth state expired, please try again.',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is missing in state', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({});
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Missing callback state parameter `provider`',
error: 'Bad Request',
});
t.assert(true);
});
test('should throw if provider is invalid in callback uri', async t => {
const { app, oauth } = t.context;
// @ts-expect-error mock
Sinon.stub(oauth, 'getOAuthState').resolves({ provider: 'Invalid' });
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.BAD_REQUEST)
.expect({
statusCode: 400,
message: 'Invalid provider',
error: 'Bad Request',
});
t.assert(true);
});
function mockOAuthProvider(app: INestApplication, email: string) {
const provider = app.get(GoogleOAuthProvider);
const oauth = app.get(OAuthService);
Sinon.stub(oauth, 'getOAuthState').resolves({
provider: OAuthProviderName.Google,
redirectUri: '/',
});
// @ts-expect-error mock
Sinon.stub(provider, 'getToken').resolves({ accessToken: '1' });
Sinon.stub(provider, 'getUser').resolves({
id: '1',
email,
avatarUrl: 'avatar',
});
}
test('should be able to sign up with oauth', async t => {
const { app, db } = t.context;
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, 'u2@affine.pro');
const user = await db.user.findFirst({
select: {
email: true,
connectedAccounts: true,
},
where: {
email: 'u2@affine.pro',
},
});
t.truthy(user);
t.is(user!.email, 'u2@affine.pro');
t.is(user!.connectedAccounts[0].providerAccountId, '1');
});
test('should throw if account register in another way', async t => {
const { app, u1 } = t.context;
mockOAuthProvider(app, u1.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The account with provided email is not register in the same way.'
);
});
test('should be able to fullfil user with oauth sign in', async t => {
const { app, user, db } = t.context;
const u3 = await user.createUser({
name: 'u3',
email: 'u3@affine.pro',
registered: false,
});
mockOAuthProvider(app, u3.email);
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.expect(HttpStatus.FOUND);
const session = await getSession(app, res);
t.truthy(session.user);
t.is(session.user!.email, u3.email);
const account = await db.connectedAccount.findFirst({
where: {
userId: u3.id,
},
});
t.truthy(account);
});
test('should throw if oauth account already connected', async t => {
const { app, db, u1, auth } = t.context;
await db.connectedAccount.create({
data: {
userId: u1.id,
provider: OAuthProviderName.Google,
providerAccountId: '1',
},
});
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: 'u2-id' });
mockOAuthProvider(app, 'u2@affine.pro');
const res = await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.FOUND);
const link = new URL(res.headers.location);
t.is(link.pathname, '/signIn');
t.is(
link.searchParams.get('error'),
'The third-party account has already been connected to another user.'
);
});
test('should be able to connect oauth account', async t => {
const { app, u1, auth, db } = t.context;
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ id: u1.id });
mockOAuthProvider(app, u1.email);
await request(app.getHttpServer())
.get(`/oauth/callback?code=1&state=1`)
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(HttpStatus.FOUND);
const account = await db.connectedAccount.findFirst({
where: {
userId: u1.id,
},
});
t.truthy(account);
t.is(account!.userId, u1.id);
});

View File

@@ -1,8 +1,7 @@
/// <reference types="../src/global.d.ts" />
import { TestingModule } from '@nestjs/testing';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import { AuthService } from '../src/core/auth';
import {

View File

@@ -1,40 +1,11 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request, { type Response } from 'supertest';
import request from 'supertest';
import {
AuthService,
type ClientTokenType,
type CurrentUser,
} from '../../src/core/auth';
import type { ClientTokenType } from '../../src/core/auth';
import type { UserType } from '../../src/core/user';
import { gql } from './common';
export function sessionCookie(headers: any) {
const cookie = headers['set-cookie']?.find((c: string) =>
c.startsWith(`${AuthService.sessionCookieName}=`)
);
if (!cookie) {
return null;
}
return cookie.split(';')[0];
}
export async function getSession(
app: INestApplication,
signInRes: Response
): Promise<{ user?: CurrentUser }> {
const cookie = sessionCookie(signInRes.headers);
const res = await request(app.getHttpServer())
.get('/api/auth/session')
.set('cookie', cookie)
.expect(200);
return res.body;
}
export async function signUp(
app: INestApplication,
name: string,

View File

@@ -113,7 +113,6 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
cors: true,
bodyParser: true,
rawBody: true,
logger: ['warn'],
});
app.use(

View File

@@ -4,12 +4,10 @@ import {
} from '@affine-test/kit/utils/cloud';
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import { AppModule } from '../src/app.module';
import { AuthService } from '../src/core/auth/service';
import { UserService } from '../src/core/user';
import { MailService } from '../src/fundamentals/mailer';
import {
acceptInviteById,
@@ -27,7 +25,6 @@ const test = ava as TestFn<{
client: PrismaClient;
auth: AuthService;
mail: MailService;
user: UserService;
}>;
test.beforeEach(async t => {
@@ -38,7 +35,6 @@ test.beforeEach(async t => {
t.context.client = app.get(PrismaClient);
t.context.auth = app.get(AuthService);
t.context.mail = app.get(MailService);
t.context.user = app.get(UserService);
});
test.afterEach.always(async t => {
@@ -99,16 +95,16 @@ test('should revoke a user', async t => {
});
test('should create user if not exist', async t => {
const { app, user } = t.context;
const { app, auth } = t.context;
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin');
const u2 = await user.findUserByEmail('u2@affine.pro');
t.not(u2, undefined, 'failed to create user');
t.is(u2?.name, 'u2', 'failed to create user');
const user = await auth.getUserByEmail('u2@affine.pro');
t.not(user, undefined, 'failed to create user');
t.is(user?.name, 'u2', 'failed to create user');
});
test('should invite a user by link', async t => {

View File

@@ -1,7 +1,6 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import ava, { type TestFn } from 'ava';
import request from 'supertest';
import { AppModule } from '../src/app.module';

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/storage",
"version": "0.14.0",
"version": "0.12.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},

View File

@@ -7,7 +7,7 @@
},
"devDependencies": {
"@types/debug": "^4.1.12",
"vitest": "1.4.0"
"vitest": "1.3.1"
},
"version": "0.14.0"
"version": "0.12.0"
}

View File

@@ -3,11 +3,11 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.14.0-canary-202403250855-4171ecd",
"@blocksuite/store": "0.14.0-canary-202403250855-4171ecd",
"@blocksuite/global": "0.13.0-canary-202403140735-2367cd5",
"@blocksuite/store": "0.13.0-canary-202403140735-2367cd5",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "1.4.0"
"vitest": "1.3.1"
},
"exports": {
"./automation": "./src/automation.ts",
@@ -26,5 +26,5 @@
"lit": "^3.1.2",
"zod": "^3.22.4"
},
"version": "0.14.0"
"version": "0.12.0"
}

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