mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-07 01:53:45 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27989c6401 | ||
|
|
d8b57d00c0 | ||
|
|
530959b868 | ||
|
|
dd2c6cf544 | ||
|
|
f5b1d041c5 | ||
|
|
81f3e65bde | ||
|
|
4389378689 | ||
|
|
364bb6ccb0 | ||
|
|
e47b271e9d | ||
|
|
72a4bf5294 | ||
|
|
f5108c6788 | ||
|
|
f9945a073c | ||
|
|
1b1e40133a | ||
|
|
003986d657 | ||
|
|
0d66519523 | ||
|
|
aaffc80f82 | ||
|
|
2c8861ae49 | ||
|
|
ec1edfd70b |
38
.eslintrc.js
38
.eslintrc.js
@@ -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',
|
||||
{
|
||||
|
||||
5
.github/actions/deploy/deploy.mjs
vendored
5
.github/actions/deploy/deploy.mjs
vendored
@@ -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}`,
|
||||
|
||||
@@ -11,7 +11,7 @@ runs:
|
||||
- name: Download tar.gz
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web
|
||||
name: core
|
||||
path: .
|
||||
|
||||
- name: Extract core artifacts
|
||||
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/deployment/node/Dockerfile
vendored
2
.github/deployment/node/Dockerfile
vendored
@@ -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 && \
|
||||
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.14.0"
|
||||
appVersion: "0.12.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
@@ -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:
|
||||
|
||||
7
.github/helm/affine/charts/graphql/templates/jwt-secret.yaml
vendored
Normal file
7
.github/helm/affine/charts/graphql/templates/jwt-secret.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: "{{ .Values.app.jwt.secretName }}"
|
||||
type: Opaque
|
||||
data:
|
||||
{{- ( include "jwt.key" . ) | indent 2 -}}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
.github/helm/affine/charts/sync/values.yaml
vendored
1
.github/helm/affine/charts/sync/values.yaml
vendored
@@ -12,6 +12,7 @@ env: 'production'
|
||||
app:
|
||||
# AFFINE_SERVER_HOST
|
||||
host: '0.0.0.0'
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
|
||||
3
.github/helm/affine/values.yaml
vendored
3
.github/helm/affine/values.yaml
vendored
@@ -4,9 +4,6 @@ global:
|
||||
className: ''
|
||||
host: affine.pro
|
||||
tls: []
|
||||
secret:
|
||||
secretName: 'server-private-key'
|
||||
privateKey: ''
|
||||
database:
|
||||
user: 'postgres'
|
||||
url: 'pg-postgresql'
|
||||
|
||||
25
.github/workflows/build-selfhost-image.yml
vendored
25
.github/workflows/build-selfhost-image.yml
vendored
@@ -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 }}
|
||||
191
.github/workflows/build-server-image.yml
vendored
191
.github/workflows/build-server-image.yml
vendored
@@ -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}}
|
||||
24
.github/workflows/build-test.yml
vendored
24
.github/workflows/build-test.yml
vendored
@@ -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
|
||||
|
||||
|
||||
200
.github/workflows/deploy.yml
vendored
200
.github/workflows/deploy.yml
vendored
@@ -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 }}
|
||||
|
||||
19
.github/workflows/release-desktop.yml
vendored
19
.github/workflows/release-desktop.yml
vendored
@@ -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
|
||||
|
||||
18
README.md
18
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
"version": "0.12.0"
|
||||
}
|
||||
|
||||
32
package.json
32
package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { type Response } from 'express';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
packages/backend/server/src/fundamentals/mutex/bucket.ts
Normal file
15
packages/backend/server/src/fundamentals/mutex/bucket.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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}]`);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,9 +19,6 @@ export class PrismaService
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (!AFFiNE.node.test) {
|
||||
await this.$disconnect();
|
||||
PrismaService.INSTANCE = null;
|
||||
}
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -113,7 +113,6 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
|
||||
cors: true,
|
||||
bodyParser: true,
|
||||
rawBody: true,
|
||||
logger: ['warn'],
|
||||
});
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user