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
716 changed files with 11472 additions and 15972 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

@@ -1,25 +0,0 @@
name: Build Selfhost Image
on:
workflow_dispatch:
inputs:
flavor:
description: 'Select distribution to build'
type: choice
default: canary
options:
- canary
- beta
- stable
permissions:
contents: 'write'
id-token: 'write'
packages: 'write'
jobs:
build-image:
name: Build Image
uses: ./.github/workflows/build-server-image.yml
with:
flavor: ${{ github.event.inputs.flavor }}

View File

@@ -1,191 +0,0 @@
name: Build Images
on:
workflow_call:
inputs:
flavor:
type: string
required: true
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
permissions:
contents: 'write'
id-token: 'write'
packages: 'write'
jobs:
build-server:
name: Build Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/server
- name: Build Server
run: yarn workspace @affine/server build
- name: Upload server dist
uses: actions/upload-artifact@v4
with:
name: server-dist
path: ./packages/backend/server/dist
if-no-files-found: error
build-web-selfhost:
name: Build @affine/web selfhost
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/web --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
PUBLIC_PATH: '/'
SELF_HOSTED: true
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/web/dist
if-no-files-found: error
build-storage:
name: Build Storage - ${{ matrix.targets.name }}
runs-on: ubuntu-latest
strategy:
matrix:
targets:
- name: x86_64-unknown-linux-gnu
file: storage.node
- name: aarch64-unknown-linux-gnu
file: storage.arm64.node
- name: armv7-unknown-linux-gnueabihf
file: storage.armv7.node
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/storage
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.targets.name }}
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload ${{ matrix.targets.file }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.targets.file }}
path: ./packages/backend/storage/storage.node
if-no-files-found: error
build-docker:
name: Build Docker
runs-on: ubuntu-latest
needs:
- build-server
- build-web-selfhost
- build-storage
steps:
- uses: actions/checkout@v4
- name: Download server dist
uses: actions/download-artifact@v4
with:
name: server-dist
path: ./packages/backend/server/dist
- name: Download storage.node
uses: actions/download-artifact@v4
with:
name: storage.node
path: ./packages/backend/server
- name: Download storage.node arm64
uses: actions/download-artifact@v4
with:
name: storage.arm64.node
path: ./packages/backend/storage
- name: Download storage.node arm64
uses: actions/download-artifact@v4
with:
name: storage.armv7.node
path: .
- name: move storage files
run: |
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
mv storage.node ./packages/backend/server/storage.armv7.node
- name: Setup env
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
if [ -z "${{ inputs.flavor }}" ]
then
echo "RELEASE_FLAVOR=canary" >> "$GITHUB_ENV"
else
echo "RELEASE_FLAVOR=${{ inputs.flavor }}" >> "$GITHUB_ENV"
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
logout: false
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# setup node without cache configuration
# Prisma cache is not compatible with docker build cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Download selfhost web artifact
uses: actions/download-artifact@v4
with:
name: selfhost-web
path: ./packages/frontend/web/dist
- name: Install Node.js dependencies
run: |
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
yarn config set --json supportedArchitectures.libc '["glibc"]'
yarn workspaces focus @affine/server --production
- name: Generate Prisma client
run: yarn workspace @affine/server prisma generate
- name: Build graphql Dockerfile
uses: docker/build-push-action@v5
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
file: .github/deployment/node/Dockerfile
tags: ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}

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,17 +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
env:
DISTRIBUTION: 'desktop'
- name: zip web
run: tar -czf dist.tar.gz --directory=packages/frontend/electron/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
@@ -487,7 +485,7 @@ jobs:
test: true,
}
needs:
- build-web
- build-core
- build-native
steps:
- uses: actions/checkout@v4
@@ -518,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

@@ -13,23 +13,33 @@ on:
- stable
- internal
env:
APP_NAME: affine
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
permissions:
contents: 'write'
id-token: 'write'
packages: 'write'
jobs:
build-server-image:
name: Build Server Image
uses: ./.github/workflows/build-server-image.yml
with:
flavor: ${{ github.event.inputs.flavor }}
build-web:
name: Build @affine/web
build-server:
name: Build Server
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/server
- name: Build Server
run: yarn workspace @affine/server build
- name: Upload server dist
uses: actions/upload-artifact@v4
with:
name: server-dist
path: ./packages/backend/server/dist
if-no-files-found: error
build-core:
name: Build @affine/core
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
@@ -40,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 }}
@@ -54,25 +64,119 @@ 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-frontend-image:
name: Build Frontend Image
build-core-selfhost:
name: Build @affine/core selfhost
runs-on: ubuntu-latest
needs:
- build-web
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Download web artifact
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Core
run: yarn nx build @affine/core --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
PUBLIC_PATH: '/'
SELF_HOSTED: true
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: selfhost-core
path: ./packages/frontend/core/dist
if-no-files-found: error
build-storage:
name: Build Storage - ${{ matrix.targets.name }}
runs-on: ubuntu-latest
strategy:
matrix:
targets:
- name: x86_64-unknown-linux-gnu
file: storage.node
- name: aarch64-unknown-linux-gnu
file: storage.arm64.node
- name: armv7-unknown-linux-gnueabihf
file: storage.armv7.node
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/storage
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.targets.name }}
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload ${{ matrix.targets.file }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.targets.file }}
path: ./packages/backend/storage/storage.node
if-no-files-found: error
build-docker:
name: Build Docker
runs-on: ubuntu-latest
permissions:
contents: 'write'
id-token: 'write'
packages: 'write'
needs:
- build-server
- 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:
name: server-dist
path: ./packages/backend/server/dist
- name: Download storage.node
uses: actions/download-artifact@v4
with:
name: storage.node
path: ./packages/backend/server
- name: Download storage.node arm64
uses: actions/download-artifact@v4
with:
name: storage.arm64.node
path: ./packages/backend/storage
- name: Download storage.node arm64
uses: actions/download-artifact@v4
with:
name: storage.armv7.node
path: .
- name: move storage files
run: |
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
mv storage.node ./packages/backend/server/storage.armv7.node
- name: Setup env
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
@@ -82,6 +186,7 @@ jobs:
else
echo "RELEASE_FLAVOR=${{ inputs.flavor }}" >> "$GITHUB_ENV"
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -104,13 +209,53 @@ jobs:
file: .github/deployment/front/Dockerfile
tags: ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}
# setup node without cache configuration
# Prisma cache is not compatible with docker build cache
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Remove core dist
run: rm -rf ./packages/frontend/core/dist
- name: Download selfhost core artifact
uses: actions/download-artifact@v4
with:
name: selfhost-core
path: ./packages/frontend/core/dist
- name: Install Node.js dependencies
run: |
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
yarn config set --json supportedArchitectures.libc '["glibc"]'
yarn workspaces focus @affine/server --production
- name: Generate Prisma client
run: yarn workspace @affine/server prisma generate
- name: Build graphql Dockerfile
uses: docker/build-push-action@v5
with:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
file: .github/deployment/node/Dockerfile
tags: ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}
deploy:
name: Deploy to cluster
if: ${{ github.event_name == 'workflow_dispatch' }}
environment: ${{ github.event.inputs.flavor }}
permissions:
contents: 'write'
id-token: 'write'
needs:
- build-frontend-image
- build-server-image
- build-docker
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -150,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:
@@ -90,10 +89,6 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_GENERATE_ASSETS: 1
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
@@ -115,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
@@ -173,10 +168,6 @@ jobs:
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
env:
SKIP_GENERATE_ASSETS: 1
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
@@ -197,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
@@ -326,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

@@ -10,7 +10,7 @@
</a>
<br/>
<p align="center">
A privacy-focused, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
One hyper-fused platform for wildly creative minds.
</p>
@@ -49,7 +49,7 @@
## Getting started & staying tuned with us.
Star us, and you will receive all release notifications from GitHub without any delay!
Star us, and you will receive all releases notifications from GitHub without any delay!
<img src="https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif" style="width: 100%"/>
@@ -65,7 +65,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
**Multimodal AI partner ready to kick in any work**
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or....draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
**Local-first & Real-time collaborative**
@@ -73,7 +73,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
**Self-host & Shape your own AFFiNE**
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks are coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks is coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
## Acknowledgement
@@ -83,7 +83,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
- Trello with their Kanban
- Airtable & Miro with their no-code programable datasheets
- Miro & Whimiscal with their edgeless visual whiteboard
- Remote & Capacities with their object-based tag system
- Remnote & Capacities with their object-based tag system
There is a large overlap of their atomic “building blocks” between these apps. They are not open source, nor do they have a plugin system like Vscode for contributors to customize. We want to have something that contains all the features we love and also goes one step even further.
@@ -104,7 +104,7 @@ For **bug reports**, **feature requests** and other **suggestions** you can also
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
Looking for **others ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide-range of support and resources.
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
@@ -147,11 +147,11 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
## Hiring
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our Discord channel for some of the latest jobs available.
Some amazing companies including AFFiNE are looking for developers! Are you interesgo to iour discord channel AFFiNE and/or its partners? Check out some of the latest [jobs available].
## Feature Request
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
For feature request, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
## Building
@@ -186,7 +186,7 @@ See [LICENSE] for details.
[jobs available]: ./docs/jobs.md
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
[rust-version-icon]: https://img.shields.io/badge/Rust-1.77.0-dea584
[rust-version-icon]: https://img.shields.io/badge/Rust-1.76.0-dea584
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success

View File

@@ -6,8 +6,8 @@ We recommend users to always use the latest major version. Security updates will
| Version | Supported |
| --------------- | ------------------ |
| 0.13.x (stable) | :white_check_mark: |
| < 0.13.x | :x: |
| 0.12.x (stable) | :white_check_mark: |
| < 0.12.x | :x: |
## Reporting a Vulnerability

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 => {

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