mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 01:23:46 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913a8fb36d | ||
|
|
d4c7d58b00 | ||
|
|
8315908490 | ||
|
|
5ca17c155a | ||
|
|
5dcb3d69e5 | ||
|
|
30b8b12703 | ||
|
|
a3cc06f3bb | ||
|
|
cccf864ed9 | ||
|
|
54c06777a6 | ||
|
|
5637676222 | ||
|
|
16063340d0 | ||
|
|
b6bba523ff | ||
|
|
8ee9f6ec05 | ||
|
|
126bfe9c6e | ||
|
|
b8e6d7d6cb | ||
|
|
0731872347 | ||
|
|
d8a3cd5ce2 | ||
|
|
af2d895e78 | ||
|
|
669ca325a1 | ||
|
|
095f8c2359 | ||
|
|
ffbfdb65a2 | ||
|
|
e9bc24bf37 | ||
|
|
2662ba763c | ||
|
|
1a1af83375 | ||
|
|
1c9d899831 | ||
|
|
00092c9955 | ||
|
|
3e547ce4cc | ||
|
|
da12a0e48e | ||
|
|
b2f34d17a2 | ||
|
|
2a019d4fae | ||
|
|
48abc52e85 | ||
|
|
09a27b6c25 | ||
|
|
03c01a9693 | ||
|
|
1ff6af85f5 | ||
|
|
6467e10690 | ||
|
|
a8cd1579f5 | ||
|
|
f2adbdaba4 | ||
|
|
7ce2bfbf0b | ||
|
|
b93871f045 | ||
|
|
d59e1389ec | ||
|
|
82cacd09d6 | ||
|
|
578d4c9775 | ||
|
|
64c011c72f | ||
|
|
2b42a75e5a | ||
|
|
c6676fd074 | ||
|
|
6a02d0bc96 | ||
|
|
6c9db367e2 | ||
|
|
a1532d4df2 | ||
|
|
7e161682f0 | ||
|
|
62a6075675 | ||
|
|
532d655ffb | ||
|
|
3c6983ee49 | ||
|
|
34703a3b7d | ||
|
|
05c44db5a9 | ||
|
|
622e90f176 | ||
|
|
a0b97f948c | ||
|
|
69cb8b0f60 | ||
|
|
150c22936d | ||
|
|
10af0ab48d | ||
|
|
aecc523663 | ||
|
|
75355867c7 | ||
|
|
85ee22329c | ||
|
|
540e456704 | ||
|
|
d03c72a0a8 | ||
|
|
6a0ab54e25 | ||
|
|
18224a83d1 | ||
|
|
f4ede22b93 | ||
|
|
8b2b2646bc | ||
|
|
e1cfa1071e | ||
|
|
e4e4a54d90 | ||
|
|
08b610bbad | ||
|
|
3edf32b1df | ||
|
|
39cde560d1 | ||
|
|
483f957583 | ||
|
|
32ab0693e2 | ||
|
|
a8a1074a8a | ||
|
|
65ab6c89bf | ||
|
|
4f5907766f | ||
|
|
06a5b2e5a5 | ||
|
|
7adb89f134 | ||
|
|
5623c0967c | ||
|
|
fce4484a85 | ||
|
|
0695544073 | ||
|
|
9030ca511e | ||
|
|
332cd3b380 | ||
|
|
26925c96e4 | ||
|
|
398d66fac1 | ||
|
|
797e3c5b35 | ||
|
|
bba1a95f9c | ||
|
|
da32682afb | ||
|
|
4702c1a9ca | ||
|
|
f18133af82 | ||
|
|
a4cd8d6ca3 | ||
|
|
a721b3887b | ||
|
|
5693d90451 | ||
|
|
dc8f351051 | ||
|
|
e896f19f1a | ||
|
|
386bd033af | ||
|
|
8301d82548 | ||
|
|
0b0b3e0ae9 | ||
|
|
268ca03f62 | ||
|
|
58c81dd1ac | ||
|
|
e94be8968b | ||
|
|
636fa503b8 | ||
|
|
eec24db1a1 | ||
|
|
7ed86a66e8 |
38
.eslintrc.js
38
.eslintrc.js
@@ -1,4 +1,4 @@
|
||||
const { resolve } = require('node:path');
|
||||
const { join } = require('node:path');
|
||||
|
||||
const createPattern = packageName => [
|
||||
{
|
||||
@@ -31,11 +31,6 @@ 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:
|
||||
@@ -93,16 +88,17 @@ const config = {
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: resolve(__dirname, './tsconfig.eslint.json'),
|
||||
project: join(__dirname, 'tsconfig.eslint.json'),
|
||||
},
|
||||
plugins: [
|
||||
'react',
|
||||
'@typescript-eslint',
|
||||
'simple-import-sort',
|
||||
'sonarjs',
|
||||
'i',
|
||||
'import-x',
|
||||
'unused-imports',
|
||||
'unicorn',
|
||||
'rxjs',
|
||||
],
|
||||
rules: {
|
||||
'array-callback-return': 'error',
|
||||
@@ -135,6 +131,7 @@ 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',
|
||||
{
|
||||
@@ -168,11 +165,6 @@ const config = {
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
{
|
||||
group: ['yjs'],
|
||||
message: 'Do not use this API because it has a bug',
|
||||
importNames: ['mergeUpdates'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -212,6 +204,21 @@ 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: [
|
||||
{
|
||||
@@ -228,9 +235,6 @@ 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',
|
||||
@@ -247,7 +251,7 @@ const config = {
|
||||
],
|
||||
'@typescript-eslint/no-misused-promises': ['error'],
|
||||
'@typescript-eslint/prefer-readonly': 'error',
|
||||
'i/no-extraneous-dependencies': ['error'],
|
||||
'import-x/no-extraneous-dependencies': ['error'],
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
5
.github/actions/deploy/deploy.mjs
vendored
5
.github/actions/deploy/deploy.mjs
vendored
@@ -21,7 +21,6 @@ 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,
|
||||
@@ -60,9 +59,7 @@ 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\\" }\"`,
|
||||
]
|
||||
@@ -114,7 +111,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=${isInternal}`,
|
||||
`--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`,
|
||||
`--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: core
|
||||
name: web
|
||||
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/core/dist ./dist
|
||||
COPY ./packages/frontend/web/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/core/dist /app/static
|
||||
COPY ./packages/frontend/web/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.12.0"
|
||||
appVersion: "0.14.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.12.0"
|
||||
appVersion: "0.14.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -61,18 +61,3 @@ 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: AUTH_PRIVATE_KEY
|
||||
- name: AFFINE_PRIVATE_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.jwt.secretName }}"
|
||||
name: "{{ .Values.global.secret.secretName }}"
|
||||
key: key
|
||||
- name: NODE_ENV
|
||||
value: "{{ .Values.env }}"
|
||||
@@ -45,8 +45,6 @@ spec:
|
||||
value: "graphql"
|
||||
- name: AFFINE_ENV
|
||||
value: "{{ .Release.Namespace }}"
|
||||
- name: NEXTAUTH_URL
|
||||
value: "{{ .Values.global.ingress.host }}"
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: "{{ .Values.app.jwt.secretName }}"
|
||||
type: Opaque
|
||||
data:
|
||||
{{- ( include "jwt.key" . ) | indent 2 -}}
|
||||
18
.github/helm/affine/charts/graphql/templates/secret.yaml
vendored
Normal file
18
.github/helm/affine/charts/graphql/templates/secret.yaml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{{- $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,10 +19,6 @@ 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.12.0"
|
||||
appVersion: "0.14.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -32,6 +32,11 @@ 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
|
||||
@@ -40,8 +45,6 @@ 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,7 +12,6 @@ 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,6 +4,9 @@ global:
|
||||
className: ''
|
||||
host: affine.pro
|
||||
tls: []
|
||||
secret:
|
||||
secretName: 'server-private-key'
|
||||
privateKey: ''
|
||||
database:
|
||||
user: 'postgres'
|
||||
url: 'pg-postgresql'
|
||||
|
||||
22
.github/workflows/build-test.yml
vendored
22
.github/workflows/build-test.yml
vendored
@@ -266,8 +266,8 @@ jobs:
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
build-core:
|
||||
name: Build @affine/core
|
||||
build-web:
|
||||
name: Build @affine/web
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -277,15 +277,15 @@ jobs:
|
||||
with:
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
- name: Build Core
|
||||
- name: Build Web
|
||||
# always skip cache because its fast, and cache configuration is always changing
|
||||
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
|
||||
run: yarn nx build @affine/web --skip-nx-cache
|
||||
- name: zip web
|
||||
run: tar -czf dist.tar.gz --directory=packages/frontend/web/dist .
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: dist.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -485,7 +485,7 @@ jobs:
|
||||
test: true,
|
||||
}
|
||||
needs:
|
||||
- build-core
|
||||
- build-web
|
||||
- build-native
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -516,8 +516,8 @@ jobs:
|
||||
shell: bash
|
||||
run: yarn workspace @affine/electron vitest
|
||||
|
||||
- name: Download core artifact
|
||||
uses: ./.github/actions/download-core
|
||||
- name: Download web artifact
|
||||
uses: ./.github/actions/download-web
|
||||
with:
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
|
||||
44
.github/workflows/deploy.yml
vendored
44
.github/workflows/deploy.yml
vendored
@@ -15,6 +15,7 @@ on:
|
||||
env:
|
||||
APP_NAME: affine
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
@@ -38,8 +39,8 @@ jobs:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
if-no-files-found: error
|
||||
build-core:
|
||||
name: Build @affine/core
|
||||
build-web:
|
||||
name: Build @affine/web
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
@@ -50,7 +51,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
run: yarn nx build @affine/web --skip-nx-cache
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
@@ -64,15 +65,15 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
- name: Upload core artifact
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
name: web
|
||||
path: ./packages/frontend/web/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-core-selfhost:
|
||||
name: Build @affine/core selfhost
|
||||
build-web-selfhost:
|
||||
name: Build @affine/web selfhost
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
@@ -83,7 +84,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
run: yarn nx build @affine/web --skip-nx-cache
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: false
|
||||
@@ -91,11 +92,11 @@ jobs:
|
||||
SELF_HOSTED: true
|
||||
- name: Download selfhost fonts
|
||||
run: node ./scripts/download-blocksuite-fonts.mjs
|
||||
- name: Upload core artifact
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-core
|
||||
path: ./packages/frontend/core/dist
|
||||
name: selfhost-web
|
||||
path: ./packages/frontend/web/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage:
|
||||
@@ -143,16 +144,16 @@ jobs:
|
||||
packages: 'write'
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
- build-core-selfhost
|
||||
- build-web
|
||||
- build-web-selfhost
|
||||
- build-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download core artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
name: web
|
||||
path: ./packages/frontend/web/dist
|
||||
- name: Download server dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -218,14 +219,14 @@ jobs:
|
||||
registry-url: https://npm.pkg.github.com
|
||||
scope: '@toeverything'
|
||||
|
||||
- name: Remove core dist
|
||||
run: rm -rf ./packages/frontend/core/dist
|
||||
- name: Remove web dist
|
||||
run: rm -rf ./packages/frontend/web/dist
|
||||
|
||||
- name: Download selfhost core artifact
|
||||
- name: Download selfhost web artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: selfhost-core
|
||||
path: ./packages/frontend/core/dist
|
||||
name: selfhost-web
|
||||
path: ./packages/frontend/web/dist
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: |
|
||||
@@ -295,7 +296,6 @@ 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 }}
|
||||
|
||||
11
.github/workflows/release-desktop.yml
vendored
11
.github/workflows/release-desktop.yml
vendored
@@ -33,6 +33,7 @@ env:
|
||||
DEBUG: napi:*
|
||||
APP_NAME: affine
|
||||
MACOSX_DEPLOYMENT_TARGET: '10.13'
|
||||
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
|
||||
|
||||
jobs:
|
||||
before-make:
|
||||
@@ -60,10 +61,10 @@ jobs:
|
||||
SKIP_PLUGIN_BUILD: 'true'
|
||||
SKIP_NX_CACHE: 'true'
|
||||
|
||||
- name: Upload core artifact
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
make-distribution:
|
||||
@@ -110,7 +111,7 @@ jobs:
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
- name: Build Desktop Layers
|
||||
@@ -188,7 +189,7 @@ jobs:
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
- name: Build Desktop Layers
|
||||
@@ -317,7 +318,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: web-static
|
||||
- name: Zip web-static
|
||||
run: zip -r web-static.zip web-static
|
||||
|
||||
@@ -55,20 +55,18 @@ 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.12.0"
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
32
package.json
32
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -17,20 +17,20 @@
|
||||
"node": "<21.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "dev-core",
|
||||
"dev": "yarn workspace @affine/cli dev",
|
||||
"dev:electron": "yarn workspace @affine/electron dev",
|
||||
"build": "yarn nx build @affine/core",
|
||||
"build": "yarn nx build @affine/web",
|
||||
"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/core static-server",
|
||||
"start:web-static": "yarn workspace @affine/web 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": "eslint . --ext .js,mjs,.ts,.tsx --cache",
|
||||
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" 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",
|
||||
"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": "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",
|
||||
"eslint --cache --fix"
|
||||
"cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" 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.0.8",
|
||||
"@nx/vite": "18.1.2",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@testing-library/react": "^14.2.1",
|
||||
@@ -74,28 +74,30 @@
|
||||
"@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.3.1",
|
||||
"@vitest/ui": "1.3.1",
|
||||
"@vitest/coverage-istanbul": "1.4.0",
|
||||
"@vitest/ui": "1.4.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^29.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-i": "^2.29.1",
|
||||
"eslint-plugin-import-x": "^0.4.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": "^13.4.1",
|
||||
"happy-dom": "^14.0.0",
|
||||
"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.0.22",
|
||||
"oxlint": "0.2.14",
|
||||
"prettier": "^3.2.5",
|
||||
"semver": "^7.6.0",
|
||||
"serve": "^14.2.1",
|
||||
@@ -105,7 +107,7 @@
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-istanbul": "^6.0.0",
|
||||
"vite-plugin-static-copy": "^1.0.1",
|
||||
"vitest": "1.3.1",
|
||||
"vitest": "1.4.0",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
@@ -168,7 +170,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@latest",
|
||||
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest",
|
||||
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
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.12.0",
|
||||
"version": "0.14.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.515.0",
|
||||
"@aws-sdk/client-s3": "^3.536.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": "^8.0.1",
|
||||
"get-stream": "^9.0.0",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-scalars": "^1.22.4",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
@@ -71,6 +71,7 @@
|
||||
"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",
|
||||
@@ -102,6 +103,7 @@
|
||||
"@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?
|
||||
subscription UserSubscription?
|
||||
subscriptions UserSubscription[]
|
||||
invoices UserInvoice[]
|
||||
workspacePermissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
@@ -369,7 +369,7 @@ model UserStripeCustomer {
|
||||
|
||||
model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @unique @map("user_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
recurring String @db.VarChar(20)
|
||||
@@ -395,6 +395,7 @@ 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,7 +1,10 @@
|
||||
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 }>}
|
||||
@@ -36,6 +39,26 @@ 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,11 +18,8 @@ import { UserModule } from './core/user';
|
||||
import { WorkspaceModule } from './core/workspaces';
|
||||
import { getOptionalModuleMetadata } from './fundamentals';
|
||||
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
|
||||
import {
|
||||
type AvailablePlugins,
|
||||
Config,
|
||||
ConfigModule,
|
||||
} from './fundamentals/config';
|
||||
import type { AvailablePlugins } from './fundamentals/config';
|
||||
import { Config, ConfigModule } from './fundamentals/config';
|
||||
import { EventModule } from './fundamentals/event';
|
||||
import { GqlModule } from './fundamentals/graphql';
|
||||
import { HelpersModule } from './fundamentals/helpers';
|
||||
|
||||
@@ -43,5 +43,12 @@ 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,7 +39,15 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
}
|
||||
|
||||
AFFiNE.plugins.use('redis');
|
||||
AFFiNE.plugins.use('payment');
|
||||
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('oauth');
|
||||
|
||||
if (AFFiNE.deploy) {
|
||||
|
||||
@@ -52,6 +52,18 @@ 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 = {
|
||||
@@ -84,15 +96,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,6 +6,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -13,11 +14,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
Config,
|
||||
PaymentRequiredException,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
|
||||
import { UserService } from '../user';
|
||||
import { validators } from '../utils/validators';
|
||||
import { CurrentUser } from './current-user';
|
||||
@@ -33,7 +30,6 @@ 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,
|
||||
@@ -58,14 +54,13 @@ 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.send(user);
|
||||
res.status(HttpStatus.OK).send(user);
|
||||
} else {
|
||||
// send email magic link
|
||||
const user = await this.user.findUserByEmail(credential.email);
|
||||
@@ -78,7 +73,7 @@ export class AuthController {
|
||||
throw new Error('Failed to send sign-in email.');
|
||||
}
|
||||
|
||||
res.send({
|
||||
res.status(HttpStatus.OK).send({
|
||||
email: credential.email,
|
||||
});
|
||||
}
|
||||
@@ -142,6 +137,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
email = decodeURIComponent(email);
|
||||
token = decodeURIComponent(token);
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
|
||||
@@ -162,22 +158,6 @@ 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 { Config, getRequestResponseFromContext } from '../../fundamentals';
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
@@ -27,7 +27,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly ref: ModuleRef,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
@@ -43,17 +42,6 @@ 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 } from './token';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule],
|
||||
@@ -17,5 +17,5 @@ export class AuthModule {}
|
||||
|
||||
export * from './guard';
|
||||
export { ClientTokenType } from './resolver';
|
||||
export { AuthService };
|
||||
export { AuthService, TokenService, TokenType };
|
||||
export * from './current-user';
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -48,6 +49,7 @@ export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly token: TokenService
|
||||
) {}
|
||||
|
||||
@@ -132,7 +134,7 @@ export class AuthResolver {
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
validators.assertValidCredential({ email, password });
|
||||
validators.assertValidEmail(email);
|
||||
const user = await this.auth.signIn(email, password);
|
||||
await this.auth.setCookie(ctx.req, ctx.res, user);
|
||||
ctx.req.user = user;
|
||||
@@ -165,7 +167,7 @@ export class AuthResolver {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(user.email, newPassword);
|
||||
await this.auth.changePassword(user.id, newPassword);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -319,7 +321,7 @@ export class AuthResolver {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const hasRegistered = await this.auth.getUserByEmail(email);
|
||||
const hasRegistered = await this.user.findUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
if (hasRegistered.id !== user.id) {
|
||||
|
||||
@@ -2,37 +2,39 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotAcceptableException,
|
||||
NotFoundException,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
|
||||
import {
|
||||
Config,
|
||||
CryptoHelper,
|
||||
MailService,
|
||||
SessionCache,
|
||||
} from '../../fundamentals';
|
||||
import { Config, CryptoHelper, MailService } 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': {
|
||||
return value;
|
||||
seq = value;
|
||||
break;
|
||||
}
|
||||
case 'string': {
|
||||
value = Number.parseInt(value);
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
const result = value.match(/^([\d{0, 10}])$/);
|
||||
if (result?.[1]) {
|
||||
seq = Number(result[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
return 0;
|
||||
seq = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, seq);
|
||||
}
|
||||
|
||||
export function sessionUser(
|
||||
@@ -56,10 +58,9 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
domain: this.config.host,
|
||||
secure: this.config.https,
|
||||
};
|
||||
static readonly sessionCookieName = 'sid';
|
||||
static readonly sessionCookieName = 'affine_session';
|
||||
static readonly authUserSeqHeaderName = 'x-auth-user';
|
||||
|
||||
constructor(
|
||||
@@ -68,8 +69,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
private readonly mailer: MailService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly user: UserService,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly cache: SessionCache
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -89,7 +89,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<CurrentUser> {
|
||||
const user = await this.getUserByEmail(email);
|
||||
const user = await this.user.findUserByEmail(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 NotFoundException('User Not Found');
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new NotAcceptableException(
|
||||
'User Password is not set. Should login throw email link.'
|
||||
'User Password is not set. Should login through email link.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,28 +125,12 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new NotAcceptableException('Incorrect Password');
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -197,7 +181,16 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
// Session
|
||||
// | { user: LimitedUser { email, avatarUrl }, expired: true }
|
||||
// | { user: User, expired: false }
|
||||
return users.map(sessionUser);
|
||||
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[];
|
||||
}
|
||||
|
||||
async signOut(token: string, seq = 0) {
|
||||
@@ -226,6 +219,10 @@ 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: {
|
||||
@@ -302,10 +299,11 @@ 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,
|
||||
req.cookies[AuthService.sessionCookieName]
|
||||
user
|
||||
// TODO(@forehalo): enable multi user session
|
||||
// req.cookies[AuthService.sessionCookieName]
|
||||
);
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, session.sessionId, {
|
||||
@@ -314,12 +312,8 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string) {
|
||||
return this.user.findUserByEmail(email);
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.getUserByEmail(email);
|
||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
@@ -338,11 +332,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -81,4 +82,15 @@ 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,6 +22,20 @@ 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({
|
||||
@@ -47,6 +61,11 @@ export class ServerConfigType {
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => CredentialsRequirementType, {
|
||||
description: 'credentials requirement',
|
||||
})
|
||||
credentialsRequirement!: CredentialsRequirementType;
|
||||
}
|
||||
|
||||
export class ServerConfigResolver {
|
||||
@@ -65,6 +84,9 @@ 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,12 +4,8 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
Config,
|
||||
type EventPayload,
|
||||
metrics,
|
||||
OnEvent,
|
||||
} from '../../fundamentals';
|
||||
import type { EventPayload } from '../../fundamentals';
|
||||
import { Config, 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,6 +55,16 @@ 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.
|
||||
@@ -272,11 +282,15 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
updates: Buffer[],
|
||||
retryTimes = 10
|
||||
) {
|
||||
const lastSeq = await this.getUpdateSeq(workspaceId, guid, updates.length);
|
||||
const now = Date.now();
|
||||
let timestamp = now;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timestamp = await new Promise<number>((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)) {
|
||||
@@ -303,14 +317,16 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
turn++;
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
})
|
||||
.pipe(retry(retryTimes)) // retry until seq num not conflict
|
||||
.subscribe({
|
||||
next: () => {
|
||||
next: timestamp => {
|
||||
this.logger.debug(
|
||||
`pushed ${updates.length} updates for ${guid} in workspace ${workspaceId}`
|
||||
);
|
||||
resolve();
|
||||
resolve(timestamp);
|
||||
},
|
||||
error: e => {
|
||||
this.logger.error('Failed to push updates', e);
|
||||
@@ -326,8 +342,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Get latest timestamp of all docs in the workspace.
|
||||
*/
|
||||
@CallTimer('doc', 'get_stats')
|
||||
async getStats(workspaceId: string, after: number | undefined = 0) {
|
||||
@CallTimer('doc', 'get_doc_timestamps')
|
||||
async getDocTimestamps(workspaceId: string, after: number | undefined = 0) {
|
||||
const snapshots = await this.db.snapshot.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -372,13 +388,18 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* get the latest doc with all update applied.
|
||||
*/
|
||||
async get(workspaceId: string, guid: string): Promise<Doc | null> {
|
||||
async get(workspaceId: string, guid: string): Promise<DocResponse | null> {
|
||||
const result = await this._get(workspaceId, guid);
|
||||
if (result) {
|
||||
if ('doc' in result) {
|
||||
return result.doc;
|
||||
} else if ('snapshot' in result) {
|
||||
return this.recoverDoc(result.snapshot);
|
||||
return result;
|
||||
} else {
|
||||
const doc = await this.recoverDoc(result.binary);
|
||||
|
||||
return {
|
||||
doc,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,13 +409,19 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* get the latest doc binary with all update applied.
|
||||
*/
|
||||
async getBinary(workspaceId: string, guid: string): Promise<Buffer | null> {
|
||||
async getBinary(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<BinaryResponse | null> {
|
||||
const result = await this._get(workspaceId, guid);
|
||||
if (result) {
|
||||
if ('doc' in result) {
|
||||
return Buffer.from(encodeStateAsUpdate(result.doc));
|
||||
} else if ('snapshot' in result) {
|
||||
return result.snapshot;
|
||||
return {
|
||||
binary: Buffer.from(encodeStateAsUpdate(result.doc)),
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,16 +431,27 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* get the latest doc state vector with all update applied.
|
||||
*/
|
||||
async getState(workspaceId: string, guid: string): Promise<Buffer | null> {
|
||||
async getDocState(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<BinaryResponse | null> {
|
||||
const snapshot = await this.getSnapshot(workspaceId, guid);
|
||||
const updates = await this.getUpdates(workspaceId, guid);
|
||||
|
||||
if (updates.length) {
|
||||
const doc = await this.squash(snapshot, updates);
|
||||
return Buffer.from(encodeStateVector(doc));
|
||||
const { doc, timestamp } = await this.squash(snapshot, updates);
|
||||
return {
|
||||
binary: Buffer.from(encodeStateVector(doc)),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot ? snapshot.state : null;
|
||||
return snapshot?.state
|
||||
? {
|
||||
binary: snapshot.state,
|
||||
timestamp: snapshot.updatedAt.getTime(),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,17 +619,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
private async _get(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<{ doc: Doc } | { snapshot: Buffer } | null> {
|
||||
): Promise<DocResponse | BinaryResponse | null> {
|
||||
const snapshot = await this.getSnapshot(workspaceId, guid);
|
||||
const updates = await this.getUpdates(workspaceId, guid);
|
||||
|
||||
if (updates.length) {
|
||||
return {
|
||||
doc: await this.squash(snapshot, updates),
|
||||
};
|
||||
return this.squash(snapshot, updates);
|
||||
}
|
||||
|
||||
return snapshot ? { snapshot: snapshot.blob } : null;
|
||||
return snapshot
|
||||
? { binary: snapshot.blob, timestamp: snapshot.updatedAt.getTime() }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,7 +637,10 @@ 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[]) {
|
||||
private async squash(
|
||||
snapshot: Snapshot | null,
|
||||
updates: Update[]
|
||||
): Promise<DocResponse> {
|
||||
if (!updates.length) {
|
||||
throw new Error('No updates to squash');
|
||||
}
|
||||
@@ -658,7 +699,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||
}
|
||||
|
||||
return doc;
|
||||
return { doc, timestamp: last.createdAt.getTime() };
|
||||
}
|
||||
|
||||
private async getUpdateSeq(workspaceId: string, guid: string, batch = 1) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
type EventPayload,
|
||||
OnEvent,
|
||||
PrismaTransaction,
|
||||
} from '../../fundamentals';
|
||||
import type { EventPayload } from '../../fundamentals';
|
||||
import { 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,13 +17,15 @@ export class WorkspaceBlobStorage {
|
||||
|
||||
constructor(
|
||||
private readonly event: EventEmitter,
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
private readonly storageFactory: StorageProviderFactory,
|
||||
private readonly cache: Cache
|
||||
) {
|
||||
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) {
|
||||
@@ -31,6 +33,16 @@ 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 => {
|
||||
@@ -38,6 +50,8 @@ export class WorkspaceBlobStorage {
|
||||
item.key = item.key.slice(workspaceId.length + 1);
|
||||
});
|
||||
|
||||
await this.cache.pushBack(`blob-list:${workspaceId}`, ...blobs);
|
||||
|
||||
return blobs;
|
||||
}
|
||||
|
||||
|
||||
@@ -246,7 +246,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
): Promise<EventResponse<Record<string, number>>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
|
||||
const stats = await this.docManager.getStats(workspaceId, timestamp);
|
||||
const stats = await this.docManager.getDocTimestamps(
|
||||
workspaceId,
|
||||
timestamp
|
||||
);
|
||||
|
||||
return {
|
||||
data: stats,
|
||||
@@ -302,13 +305,15 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
guid: string;
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
||||
): Promise<
|
||||
EventResponse<{ missing: string; state?: string; timestamp: number }>
|
||||
> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||
const res = await this.docManager.get(docId.workspace, docId.guid);
|
||||
|
||||
if (!doc) {
|
||||
if (!res) {
|
||||
return {
|
||||
error: new DocNotFoundError(workspaceId, docId.guid),
|
||||
};
|
||||
@@ -316,16 +321,17 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
const missing = Buffer.from(
|
||||
encodeStateAsUpdate(
|
||||
doc,
|
||||
res.doc,
|
||||
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
||||
)
|
||||
).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(res.doc)).toString('base64');
|
||||
|
||||
return {
|
||||
data: {
|
||||
missing,
|
||||
state,
|
||||
timestamp: res.timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } 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,12 +5,13 @@ 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(minPasswordLength, {
|
||||
message: `Password must be ${minPasswordLength} or more charactors long`,
|
||||
.min(AFFiNE.auth.password.minLength, {
|
||||
message: `Password must be ${AFFiNE.auth.password.minLength} or more charactors long`,
|
||||
})
|
||||
.max(20, { message: 'Password must be 20 or fewer charactors long' });
|
||||
.max(AFFiNE.auth.password.maxLength, {
|
||||
message: `Password must be ${AFFiNE.auth.password.maxLength} 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.toISOString());
|
||||
res.setHeader('last-modified', metadata.lastModified.toUTCString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
@@ -83,9 +83,12 @@ export class WorkspacesController {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
}
|
||||
|
||||
const update = await this.docManager.getBinary(docId.workspace, docId.guid);
|
||||
const binResponse = await this.docManager.getBinary(
|
||||
docId.workspace,
|
||||
docId.guid
|
||||
);
|
||||
|
||||
if (!update) {
|
||||
if (!binResponse) {
|
||||
throw new NotFoundException('Doc not found');
|
||||
}
|
||||
|
||||
@@ -106,8 +109,12 @@ export class WorkspacesController {
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.setHeader('cache-control', 'no-cache');
|
||||
res.send(update);
|
||||
res.setHeader(
|
||||
'last-modified',
|
||||
new Date(binResponse.timestamp).toUTCString()
|
||||
);
|
||||
res.setHeader('cache-control', 'private, max-age=2592000');
|
||||
res.send(binResponse.binary);
|
||||
}
|
||||
|
||||
@Get('/:id/docs/:guid/histories/:timestamp')
|
||||
@@ -142,7 +149,7 @@ export class WorkspacesController {
|
||||
|
||||
if (history) {
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||
res.send(history.blob);
|
||||
} else {
|
||||
throw new NotFoundException('Doc history not found');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { type Prisma, PrismaClient } from '@prisma/client';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { 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,10 +9,8 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import {
|
||||
PrismaClient,
|
||||
type WorkspacePage as PrismaWorkspacePage,
|
||||
} from '@prisma/client';
|
||||
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
||||
import { PrismaClient } 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,6 +23,8 @@ 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,9 +3,8 @@ 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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export class UnamedAccount1703756315970 {
|
||||
// do the migration
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { UserService } from '../../core/user';
|
||||
import { Config, CryptoHelper } from '../../fundamentals';
|
||||
|
||||
export class SelfHostAdmin1605053000403 {
|
||||
export class SelfHostAdmin99999999 {
|
||||
// do the migration
|
||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
||||
const config = ref.get(Config, { strict: false });
|
||||
@@ -13,12 +13,6 @@ 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';
|
||||
@@ -220,6 +214,25 @@ 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
|
||||
@@ -319,6 +332,11 @@ export interface AFFiNEConfig {
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export * from './storage';
|
||||
|
||||
@@ -5,13 +5,8 @@ import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import pkg from '../../../package.json' assert { type: 'json' };
|
||||
import {
|
||||
type AFFINE_ENV,
|
||||
AFFiNEConfig,
|
||||
DeploymentType,
|
||||
type NODE_ENV,
|
||||
type ServerFlavor,
|
||||
} from './def';
|
||||
import type { AFFINE_ENV, NODE_ENV, ServerFlavor } from './def';
|
||||
import { AFFiNEConfig, DeploymentType } from './def';
|
||||
import { readEnv } from './env';
|
||||
import { getDefaultAFFiNEStorageConfig } from './storage';
|
||||
|
||||
@@ -25,9 +20,10 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
const ONE_DAY_IN_SEC = 60 * 60 * 24;
|
||||
|
||||
const keyPair = (function () {
|
||||
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const AFFINE_PRIVATE_KEY =
|
||||
process.env.AFFINE_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
key: Buffer.from(AFFINE_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
@@ -37,7 +33,7 @@ const keyPair = (function () {
|
||||
})
|
||||
.toString('utf8');
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
key: Buffer.from(AFFINE_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
@@ -77,7 +73,16 @@ 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',
|
||||
@@ -98,19 +103,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
ENV_MAP: {},
|
||||
AFFINE_ENV,
|
||||
get affine() {
|
||||
return {
|
||||
canary: AFFINE_ENV === 'dev',
|
||||
beta: AFFINE_ENV === 'beta',
|
||||
stable: AFFINE_ENV === 'production',
|
||||
};
|
||||
return affine;
|
||||
},
|
||||
NODE_ENV,
|
||||
get node() {
|
||||
return {
|
||||
prod: NODE_ENV === 'production',
|
||||
dev: NODE_ENV === 'development',
|
||||
test: NODE_ENV === 'test',
|
||||
};
|
||||
return node;
|
||||
},
|
||||
get deploy() {
|
||||
return !this.node.dev && !this.node.test;
|
||||
@@ -150,6 +147,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
playground: true,
|
||||
},
|
||||
auth: {
|
||||
password: {
|
||||
minLength: node.prod ? 8 : 1,
|
||||
maxLength: 32,
|
||||
},
|
||||
session: {
|
||||
ttl: 15 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
@@ -186,6 +187,10 @@ 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,13 +18,7 @@ export type { GraphqlContext } from './graphql';
|
||||
export { CryptoHelper, URLHelper } from './helpers';
|
||||
export { MailService } from './mailer';
|
||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||
export {
|
||||
BucketService,
|
||||
LockGuard,
|
||||
MUTEX_RETRY,
|
||||
MUTEX_WAIT,
|
||||
MutexService,
|
||||
} from './mutex';
|
||||
export { type ILocker, Lock, Locker, MutexService } from './mutex';
|
||||
export {
|
||||
getOptionalModuleMetadata,
|
||||
GlobalExceptionFilter,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { URLHelper } from '../helpers';
|
||||
import { MAILER_SERVICE, type MailerService, type Options } from './mailer';
|
||||
import type { MailerService, Options } from './mailer';
|
||||
import { MAILER_SERVICE } 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,11 +15,8 @@ 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,
|
||||
MetricProducer,
|
||||
MetricReader,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import type { MeterProvider } from '@opentelemetry/sdk-metrics';
|
||||
import { MetricProducer, MetricReader } from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
BatchSpanProcessor,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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 { BucketService } from './bucket';
|
||||
import { Locker } from './local-lock';
|
||||
import { MutexService } from './mutex';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [BucketService, MutexService],
|
||||
exports: [BucketService, MutexService],
|
||||
providers: [MutexService, Locker],
|
||||
exports: [MutexService],
|
||||
})
|
||||
export class MutexModule {}
|
||||
|
||||
export { BucketService, MutexService };
|
||||
export { LockGuard, MUTEX_RETRY, MUTEX_WAIT } from './mutex';
|
||||
export { Locker, MutexService };
|
||||
export { type Locker as ILocker, Lock } from './lock';
|
||||
|
||||
28
packages/backend/server/src/fundamentals/mutex/local-lock.ts
Normal file
28
packages/backend/server/src/fundamentals/mutex/local-lock.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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}]`);
|
||||
}
|
||||
}
|
||||
23
packages/backend/server/src/fundamentals/mutex/lock.ts
Normal file
23
packages/backend/server/src/fundamentals/mutex/lock.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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,24 +1,12 @@
|
||||
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 { 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);
|
||||
}
|
||||
}
|
||||
import { retryable } from '../utils/promise';
|
||||
import { Locker } from './local-lock';
|
||||
|
||||
export const MUTEX_RETRY = 5;
|
||||
export const MUTEX_WAIT = 100;
|
||||
@@ -26,11 +14,21 @@ 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 bucket: BucketService
|
||||
) {}
|
||||
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 });
|
||||
}
|
||||
|
||||
protected getId() {
|
||||
let id = this.context.req.headers['x-transaction-id'] as string;
|
||||
@@ -64,33 +62,19 @@ export class MutexService {
|
||||
* @param key resource key
|
||||
* @returns LockGuard
|
||||
*/
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import { PrismaService } from './service';
|
||||
// only `PrismaClient` can be injected
|
||||
const clientProvider: Provider = {
|
||||
provide: PrismaClient,
|
||||
useClass: PrismaService,
|
||||
useFactory: () => {
|
||||
if (PrismaService.INSTANCE) {
|
||||
return PrismaService.INSTANCE;
|
||||
}
|
||||
|
||||
return new PrismaService();
|
||||
},
|
||||
};
|
||||
|
||||
@Global()
|
||||
|
||||
@@ -19,6 +19,9 @@ export class PrismaService
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
if (!AFFiNE.node.test) {
|
||||
await this.$disconnect();
|
||||
PrismaService.INSTANCE = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
|
||||
import {
|
||||
Throttle,
|
||||
ThrottlerGuard,
|
||||
|
||||
44
packages/backend/server/src/fundamentals/utils/promise.ts
Normal file
44
packages/backend/server/src/fundamentals/utils/promise.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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 provider');
|
||||
throw new BadRequestException('Invalid OAuth 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',
|
||||
promot: 'select_account',
|
||||
prompt: 'select_account',
|
||||
access_type: 'offline',
|
||||
...this.config.args,
|
||||
state,
|
||||
|
||||
@@ -16,7 +16,7 @@ export function registerOAuthProvider(
|
||||
@Injectable()
|
||||
export class OAuthProviderFactory {
|
||||
get providers() {
|
||||
return PROVIDERS.keys();
|
||||
return Array.from(PROVIDERS.keys());
|
||||
}
|
||||
|
||||
get(name: OAuthProviderName): OAuthProvider | undefined {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { BadGatewayException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
@@ -48,11 +44,11 @@ class SubscriptionPrice {
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
@Field(() => Int, { nullable: true })
|
||||
amount?: number | null;
|
||||
|
||||
@Field()
|
||||
yearlyAmount!: number;
|
||||
@Field(() => Int, { nullable: true })
|
||||
yearlyAmount?: number | null;
|
||||
}
|
||||
|
||||
@ObjectType('UserSubscription')
|
||||
@@ -176,64 +172,39 @@ export class SubscriptionResolver {
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
function findPrice(plan: SubscriptionPlan) {
|
||||
const prices = group[plan];
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new InternalServerErrorException(
|
||||
'The prices are not configured correctly.'
|
||||
);
|
||||
if (!prices) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
type: 'fixed',
|
||||
plan: plan as SubscriptionPlan,
|
||||
currency: monthly.currency,
|
||||
amount: monthly.unit_amount ?? 0,
|
||||
yearlyAmount: yearly.unit_amount ?? 0,
|
||||
currency,
|
||||
amount: monthlyPrice?.unit_amount,
|
||||
yearlyAmount: yearlyPrice?.unit_amount,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.');
|
||||
}
|
||||
|
||||
return session.url;
|
||||
// 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[]);
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
@@ -271,17 +242,35 @@ 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);
|
||||
return this.service.cancelSubscription(idempotencyKey, user.id, plan);
|
||||
}
|
||||
|
||||
@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);
|
||||
return this.service.resumeCanceledSubscription(
|
||||
idempotencyKey,
|
||||
user.id,
|
||||
plan
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
@@ -289,11 +278,19 @@ 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
|
||||
);
|
||||
}
|
||||
@@ -306,11 +303,21 @@ export class UserSubscriptionResolver {
|
||||
private readonly db: PrismaClient
|
||||
) {}
|
||||
|
||||
@ResolveField(() => UserSubscriptionType, { nullable: true })
|
||||
@ResolveField(() => UserSubscriptionType, {
|
||||
nullable: true,
|
||||
deprecationReason: 'use `UserType.subscriptions`',
|
||||
})
|
||||
async subscription(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() me: User,
|
||||
@Parent() user: User
|
||||
@Parent() user: User,
|
||||
@Args({
|
||||
name: 'plan',
|
||||
type: () => SubscriptionPlan,
|
||||
nullable: true,
|
||||
defaultValue: SubscriptionPlan.Pro,
|
||||
})
|
||||
plan: SubscriptionPlan
|
||||
) {
|
||||
// allow admin to query other user's subscription
|
||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||
@@ -340,12 +347,33 @@ export class UserSubscriptionResolver {
|
||||
|
||||
return this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserInvoiceType])
|
||||
async invoices(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
||||
import type {
|
||||
Prisma,
|
||||
@@ -65,7 +65,9 @@ export class SubscriptionService {
|
||||
) {}
|
||||
|
||||
async listPrices() {
|
||||
return this.stripe.prices.list();
|
||||
return this.stripe.prices.list({
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
async createCheckoutSession({
|
||||
@@ -86,12 +88,15 @@ export class SubscriptionService {
|
||||
const currentSubscription = await this.db.userSubscription.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
status: SubscriptionStatus.Active,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSubscription) {
|
||||
throw new Error('You already have a subscription');
|
||||
throw new BadRequestException(
|
||||
`You've already subscripted to the ${plan} plan`
|
||||
);
|
||||
}
|
||||
|
||||
const price = await this.getPrice(plan, recurring);
|
||||
@@ -152,35 +157,47 @@ export class SubscriptionService {
|
||||
|
||||
async cancelSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
plan: SubscriptionPlan
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
subscriptions: {
|
||||
where: {
|
||||
plan,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
if (!user) {
|
||||
throw new BadRequestException('Unknown user');
|
||||
}
|
||||
|
||||
if (user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled');
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
// should release the schedule first
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
if (subscriptionInDB.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
subscriptionInDB.stripeScheduleId
|
||||
);
|
||||
await manager.cancel(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
subscriptionInDB.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
@@ -188,7 +205,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(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
subscriptionInDB.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: true },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
@@ -198,44 +215,52 @@ export class SubscriptionService {
|
||||
|
||||
async resumeCanceledSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
plan: SubscriptionPlan
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
if (!user) {
|
||||
throw new BadRequestException('Unknown user');
|
||||
}
|
||||
|
||||
if (!user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has not been canceled');
|
||||
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.end < new Date()) {
|
||||
throw new Error('Your subscription is expired, please checkout again.');
|
||||
if (!subscriptionInDB.canceledAt) {
|
||||
throw new BadRequestException('Your subscription has not been canceled');
|
||||
}
|
||||
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
if (subscriptionInDB.end < new Date()) {
|
||||
throw new BadRequestException(
|
||||
'Your subscription is expired, please checkout again.'
|
||||
);
|
||||
}
|
||||
|
||||
if (subscriptionInDB.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
subscriptionInDB.stripeScheduleId
|
||||
);
|
||||
await manager.resume(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
subscriptionInDB.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
const subscription = await this.stripe.subscriptions.update(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
subscriptionInDB.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: false },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
@@ -247,6 +272,7 @@ export class SubscriptionService {
|
||||
async updateSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
userId: string,
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
@@ -254,30 +280,38 @@ export class SubscriptionService {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
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.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled ');
|
||||
if (subscriptionInDB.canceledAt) {
|
||||
throw new BadRequestException(
|
||||
'Your subscription has already been canceled '
|
||||
);
|
||||
}
|
||||
|
||||
if (user.subscription.recurring === recurring) {
|
||||
throw new Error('You have already subscribed to this plan');
|
||||
if (subscriptionInDB.recurring === recurring) {
|
||||
throw new BadRequestException(
|
||||
`You are already in ${recurring} recurring`
|
||||
);
|
||||
}
|
||||
|
||||
const price = await this.getPrice(
|
||||
user.subscription.plan as SubscriptionPlan,
|
||||
subscriptionInDB.plan as SubscriptionPlan,
|
||||
recurring
|
||||
);
|
||||
|
||||
const manager = await this.scheduleManager.fromSubscription(
|
||||
`${idempotencyKey}-fromSubscription`,
|
||||
user.subscription.stripeSubscriptionId
|
||||
subscriptionInDB.stripeSubscriptionId
|
||||
);
|
||||
|
||||
await manager.update(
|
||||
@@ -293,7 +327,7 @@ export class SubscriptionService {
|
||||
|
||||
return await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: user.subscription.id,
|
||||
id: subscriptionInDB.id,
|
||||
},
|
||||
data: {
|
||||
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
|
||||
@@ -310,7 +344,7 @@ export class SubscriptionService {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Unknown user');
|
||||
throw new BadRequestException('Unknown user');
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -321,7 +355,7 @@ export class SubscriptionService {
|
||||
return portal.url;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to create customer portal.', e);
|
||||
throw new Error('Failed to create customer portal');
|
||||
throw new BadRequestException('Failed to create customer portal');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +552,10 @@ export class SubscriptionService {
|
||||
|
||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
userId_plan: {
|
||||
userId: user.id,
|
||||
plan,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -641,8 +678,8 @@ export class SubscriptionService {
|
||||
});
|
||||
|
||||
if (!prices.data.length) {
|
||||
throw new Error(
|
||||
`Unknown subscription plan ${plan} with recurring ${recurring}`
|
||||
throw new BadRequestException(
|
||||
`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,6 +20,7 @@ export enum SubscriptionRecurring {
|
||||
export enum SubscriptionPlan {
|
||||
Free = 'free',
|
||||
Pro = 'pro',
|
||||
AI = 'ai',
|
||||
Team = 'team',
|
||||
Enterprise = 'enterprise',
|
||||
SelfHosted = 'selfhosted',
|
||||
|
||||
@@ -12,6 +12,7 @@ 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')
|
||||
@@ -28,6 +29,7 @@ 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,27 +1,15 @@
|
||||
import { Global, Provider, Type } from '@nestjs/common';
|
||||
import { CONTEXT } from '@nestjs/graphql';
|
||||
import { Redis, type RedisOptions } from 'ioredis';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis';
|
||||
|
||||
import {
|
||||
BucketService,
|
||||
Cache,
|
||||
type GraphqlContext,
|
||||
MutexService,
|
||||
SessionCache,
|
||||
} from '../../fundamentals';
|
||||
import { Cache, Locker, SessionCache } from '../../fundamentals';
|
||||
import { ThrottlerStorage } from '../../fundamentals/throttler';
|
||||
import { SocketIoAdapterImpl } from '../../fundamentals/websocket';
|
||||
import { Plugin } from '../registry';
|
||||
import { RedisCache } from './cache';
|
||||
import {
|
||||
CacheRedis,
|
||||
MutexRedis,
|
||||
SessionRedis,
|
||||
SocketIoRedis,
|
||||
ThrottlerRedis,
|
||||
} from './instances';
|
||||
import { MutexRedisService } from './mutex';
|
||||
import { CacheRedis, SessionRedis, SocketIoRedis } from './instances';
|
||||
import { RedisMutexLocker } from './mutex';
|
||||
import { createSockerIoAdapterImpl } from './ws-adapter';
|
||||
|
||||
function makeProvider(token: Type, impl: Type<Redis>): Provider {
|
||||
@@ -44,7 +32,7 @@ const throttlerStorageProvider: Provider = {
|
||||
useFactory: (redis: Redis) => {
|
||||
return new ThrottlerStorageRedisService(redis);
|
||||
},
|
||||
inject: [ThrottlerRedis],
|
||||
inject: [SessionRedis],
|
||||
};
|
||||
|
||||
// socket io
|
||||
@@ -58,23 +46,14 @@ const socketIoRedisAdapterProvider: Provider = {
|
||||
|
||||
// mutex
|
||||
const mutexRedisAdapterProvider: Provider = {
|
||||
provide: MutexService,
|
||||
useFactory: (redis: Redis, ctx: GraphqlContext, bucket: BucketService) => {
|
||||
return new MutexRedisService(redis, ctx, bucket);
|
||||
},
|
||||
inject: [MutexRedis, CONTEXT, BucketService],
|
||||
provide: Locker,
|
||||
useClass: RedisMutexLocker,
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Plugin({
|
||||
name: 'redis',
|
||||
providers: [
|
||||
CacheRedis,
|
||||
SessionRedis,
|
||||
ThrottlerRedis,
|
||||
SocketIoRedis,
|
||||
MutexRedis,
|
||||
],
|
||||
providers: [CacheRedis, SessionRedis, SocketIoRedis],
|
||||
overrides: [
|
||||
cacheProvider,
|
||||
sessionCacheProvider,
|
||||
|
||||
@@ -34,13 +34,6 @@ 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) {
|
||||
@@ -54,10 +47,3 @@ 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,96 +1,65 @@
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Redis, { Command } from 'ioredis';
|
||||
import { Command } from 'ioredis';
|
||||
|
||||
import {
|
||||
BucketService,
|
||||
type GraphqlContext,
|
||||
LockGuard,
|
||||
MUTEX_RETRY,
|
||||
MUTEX_WAIT,
|
||||
MutexService,
|
||||
} from '../../fundamentals';
|
||||
import { ILocker, Lock } from '../../fundamentals';
|
||||
import { SessionRedis } from './instances';
|
||||
|
||||
// === 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 clientId = ARGV[1]
|
||||
local releaseTime = ARGV[2]
|
||||
local owner = ARGV[1]
|
||||
|
||||
if redis.call("get", key) == clientId or redis.call("set", key, clientId, "NX", "PX", releaseTime) then
|
||||
-- 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
|
||||
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 clientId = ARGV[1]
|
||||
local owner = ARGV[1]
|
||||
|
||||
if redis.call("get", key) == clientId then
|
||||
local value = redis.call("get", key)
|
||||
if value == owner then
|
||||
return redis.call("del", key)
|
||||
elseif value == nil then
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end`;
|
||||
|
||||
@Injectable()
|
||||
export class MutexRedisService extends MutexService {
|
||||
constructor(
|
||||
private readonly redis: Redis,
|
||||
context: GraphqlContext,
|
||||
bucket: BucketService
|
||||
) {
|
||||
super(context, bucket);
|
||||
this.logger = new Logger(MutexRedisService.name);
|
||||
}
|
||||
export class RedisMutexLocker implements ILocker {
|
||||
private readonly logger = new Logger(RedisMutexLocker.name);
|
||||
constructor(private readonly redis: SessionRedis) {}
|
||||
|
||||
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();
|
||||
async lock(owner: string, key: string): Promise<Lock> {
|
||||
const lockKey = `MutexLock:${key}`;
|
||||
this.logger.debug(`Client ${owner} is trying to lock resource ${key}`);
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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])
|
||||
const success = await this.redis.sendCommand(
|
||||
new Command('EVAL', [lockScript, '1', lockKey, owner])
|
||||
);
|
||||
if (result === 0) {
|
||||
if (!ignoreUnlockFail) {
|
||||
throw new Error(`Failed to release lock ${key}`);
|
||||
} else {
|
||||
this.logger.warn(`Failed to release lock ${key}`);
|
||||
}
|
||||
|
||||
if (success === 1) {
|
||||
return new Lock(async () => {
|
||||
const result = await this.redis.sendCommand(
|
||||
new Command('EVAL', [unlockScript, '1', lockKey, owner])
|
||||
);
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error(`Failed to release lock ${key}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Failed to acquire lock for resource [${key}]`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ 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.
|
||||
"""
|
||||
@@ -110,13 +114,10 @@ type Mutation {
|
||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
|
||||
addToEarlyAccess(email: String!): Int!
|
||||
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
cancelSubscription(idempotencyKey: String!): UserSubscription!
|
||||
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): 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!
|
||||
|
||||
@@ -137,7 +138,7 @@ type Mutation {
|
||||
removeAvatar: RemoveAvatar!
|
||||
removeEarlyAccess(email: String!): Int!
|
||||
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
|
||||
resumeSubscription(idempotencyKey: String!): UserSubscription!
|
||||
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
|
||||
revoke(userId: String!, workspaceId: String!): Boolean!
|
||||
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
|
||||
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
|
||||
@@ -152,7 +153,7 @@ type Mutation {
|
||||
signIn(email: String!, password: String!): UserType!
|
||||
signUp(email: String!, name: String!, password: String!): UserType!
|
||||
updateProfile(input: UpdateUserInput!): UserType!
|
||||
updateSubscriptionRecurring(idempotencyKey: String!, recurring: SubscriptionRecurring!): UserSubscription!
|
||||
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
|
||||
|
||||
"""Update workspace"""
|
||||
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
|
||||
@@ -167,6 +168,11 @@ enum OAuthProviderType {
|
||||
Google
|
||||
}
|
||||
|
||||
type PasswordLimitsType {
|
||||
maxLength: Int!
|
||||
minLength: Int!
|
||||
}
|
||||
|
||||
"""User permission in workspace"""
|
||||
enum Permission {
|
||||
Admin
|
||||
@@ -239,6 +245,9 @@ type ServerConfigType {
|
||||
"""server base url"""
|
||||
baseUrl: String!
|
||||
|
||||
"""credentials requirement"""
|
||||
credentialsRequirement: CredentialsRequirementType!
|
||||
|
||||
"""enabled server features"""
|
||||
features: [ServerFeature!]!
|
||||
|
||||
@@ -267,6 +276,7 @@ enum ServerFeature {
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
AI
|
||||
Enterprise
|
||||
Free
|
||||
Pro
|
||||
@@ -275,11 +285,11 @@ enum SubscriptionPlan {
|
||||
}
|
||||
|
||||
type SubscriptionPrice {
|
||||
amount: Int!
|
||||
amount: Int
|
||||
currency: String!
|
||||
plan: SubscriptionPlan!
|
||||
type: String!
|
||||
yearlyAmount: Int!
|
||||
yearlyAmount: Int
|
||||
}
|
||||
|
||||
enum SubscriptionRecurring {
|
||||
@@ -388,7 +398,8 @@ type UserType {
|
||||
"""User name"""
|
||||
name: String!
|
||||
quota: UserQuota
|
||||
subscription: UserSubscription
|
||||
subscription(plan: SubscriptionPlan = Pro): UserSubscription @deprecated(reason: "use `UserType.subscriptions`")
|
||||
subscriptions: [UserSubscription!]!
|
||||
token: tokenType! @deprecated(reason: "use [/api/auth/authorize]")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
getLatestMailMessage,
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { AuthService } from '../src/core/auth/service';
|
||||
import { MailService } from '../src/fundamentals/mailer';
|
||||
|
||||
164
packages/backend/server/tests/auth/controller.spec.ts
Normal file
164
packages/backend/server/tests/auth/controller.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
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);
|
||||
});
|
||||
131
packages/backend/server/tests/auth/guard.spec.ts
Normal file
131
packages/backend/server/tests/auth/guard.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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]);
|
||||
});
|
||||
219
packages/backend/server/tests/auth/service.spec.ts
Normal file
219
packages/backend/server/tests/auth/service.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
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);
|
||||
});
|
||||
93
packages/backend/server/tests/auth/token.spec.ts
Normal file
93
packages/backend/server/tests/auth/token.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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'))?.toString('hex'),
|
||||
(await manager.getBinary(ws.id, '1'))?.binary.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'))?.toString('hex'),
|
||||
(await manager.getBinary(ws.id, '1'))?.binary.toString('hex'),
|
||||
Buffer.from(encodeStateAsUpdate(doc)).toString('hex')
|
||||
);
|
||||
});
|
||||
@@ -275,20 +275,21 @@ 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);
|
||||
|
||||
await manager.push('1', '1', Buffer.from(update));
|
||||
{
|
||||
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));
|
||||
}
|
||||
const updates = await manager.getUpdates('1', '1');
|
||||
t.is(updates.length, 1);
|
||||
// @ts-expect-error private
|
||||
const snapshot = await manager.squash(null, updates);
|
||||
const { doc } = await manager.squash(null, updates);
|
||||
|
||||
t.truthy(snapshot);
|
||||
t.is(snapshot.getText('content').toString(), 'hello');
|
||||
t.truthy(doc);
|
||||
t.is(doc.getText('content').toString(), 'hello');
|
||||
|
||||
const restUpdates = await manager.getUpdates('1', '1');
|
||||
|
||||
@@ -315,14 +316,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!');
|
||||
}
|
||||
@@ -372,7 +373,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,7 +2,8 @@
|
||||
|
||||
import { INestApplication, Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava 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,7 +7,8 @@ import {
|
||||
getLatestMailMessage,
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { AuthService } from '../src/core/auth/service';
|
||||
import { ConfigModule } from '../src/fundamentals/config';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
345
packages/backend/server/tests/oauth/controller.spec.ts
Normal file
345
packages/backend/server/tests/oauth/controller.spec.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
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,7 +1,8 @@
|
||||
/// <reference types="../src/global.d.ts" />
|
||||
|
||||
import { TestingModule } from '@nestjs/testing';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
|
||||
import { AuthService } from '../src/core/auth';
|
||||
import {
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import request, { type Response } from 'supertest';
|
||||
|
||||
import type { ClientTokenType } from '../../src/core/auth';
|
||||
import {
|
||||
AuthService,
|
||||
type ClientTokenType,
|
||||
type CurrentUser,
|
||||
} 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,6 +113,7 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
|
||||
cors: true,
|
||||
bodyParser: true,
|
||||
rawBody: true,
|
||||
logger: ['warn'],
|
||||
});
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
} from '@affine-test/kit/utils/cloud';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava 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,
|
||||
@@ -25,6 +27,7 @@ const test = ava as TestFn<{
|
||||
client: PrismaClient;
|
||||
auth: AuthService;
|
||||
mail: MailService;
|
||||
user: UserService;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
@@ -35,6 +38,7 @@ 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 => {
|
||||
@@ -95,16 +99,16 @@ test('should revoke a user', async t => {
|
||||
});
|
||||
|
||||
test('should create user if not exist', async t => {
|
||||
const { app, auth } = t.context;
|
||||
const { app, user } = 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 user = await auth.getUserByEmail('u2@affine.pro');
|
||||
t.not(user, undefined, 'failed to create user');
|
||||
t.is(user?.name, 'u2', 'failed to create user');
|
||||
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');
|
||||
});
|
||||
|
||||
test('should invite a user by link', async t => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { type TestFn } from 'ava';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/storage",
|
||||
"version": "0.12.0",
|
||||
"version": "0.14.0",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "1.3.1"
|
||||
"vitest": "1.4.0"
|
||||
},
|
||||
"version": "0.12.0"
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
8
packages/common/env/package.json
vendored
8
packages/common/env/package.json
vendored
@@ -3,11 +3,11 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.13.0-canary-202403140735-2367cd5",
|
||||
"@blocksuite/store": "0.13.0-canary-202403140735-2367cd5",
|
||||
"@blocksuite/global": "0.14.0-canary-202403250855-4171ecd",
|
||||
"@blocksuite/store": "0.14.0-canary-202403250855-4171ecd",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"vitest": "1.3.1"
|
||||
"vitest": "1.4.0"
|
||||
},
|
||||
"exports": {
|
||||
"./automation": "./src/automation.ts",
|
||||
@@ -26,5 +26,5 @@
|
||||
"lit": "^3.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"version": "0.12.0"
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user