mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 03:48:39 +00:00
Compare commits
206 Commits
v0.13.0-ca
...
v0.14.0-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296362ced1 | ||
|
|
f1c70d5df0 | ||
|
|
c078f32a83 | ||
|
|
605a5abee8 | ||
|
|
0abd24654d | ||
|
|
35715ab1d8 | ||
|
|
1a873ecf3c | ||
|
|
0ce6401a6f | ||
|
|
d14552b5af | ||
|
|
7cd75824a4 | ||
|
|
c2847e2082 | ||
|
|
88d04e23e9 | ||
|
|
7526dea705 | ||
|
|
ef354f1643 | ||
|
|
8ed38d7c0d | ||
|
|
fbe7a346c6 | ||
|
|
f69649c922 | ||
|
|
e53744b740 | ||
|
|
4e7652f108 | ||
|
|
e24b6e4ddc | ||
|
|
ba9dad95b4 | ||
|
|
710edd28db | ||
|
|
d412635f6b | ||
|
|
39facba92e | ||
|
|
d4c7d58b00 | ||
|
|
5ca17c155a | ||
|
|
5dcb3d69e5 | ||
|
|
30b8b12703 | ||
|
|
a3cc06f3bb | ||
|
|
cccf864ed9 | ||
|
|
54c06777a6 | ||
|
|
5637676222 | ||
|
|
16063340d0 | ||
|
|
b6bba523ff | ||
|
|
8ee9f6ec05 | ||
|
|
b8e6d7d6cb | ||
|
|
0731872347 | ||
|
|
d8a3cd5ce2 | ||
|
|
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 | ||
|
|
bc465f9704 | ||
|
|
a24320da68 | ||
|
|
78f7e3a45e | ||
|
|
34f892b05b | ||
|
|
fd4e4123f5 | ||
|
|
9ba47f43bb | ||
|
|
12c04a8575 | ||
|
|
96c67afc11 | ||
|
|
ee75b468e6 | ||
|
|
e35f6dca0e | ||
|
|
5a0e207a8c | ||
|
|
533c181640 | ||
|
|
79ffca314d | ||
|
|
7fdb1f2d97 | ||
|
|
b9fc848824 | ||
|
|
d2bad68b74 | ||
|
|
dd9a253772 | ||
|
|
05583dbe98 | ||
|
|
c5d8c6cc8c | ||
|
|
c1f5e848b4 | ||
|
|
e116a82c24 | ||
|
|
282b788258 | ||
|
|
f2ec81b2d0 | ||
|
|
fddbb426a6 | ||
|
|
573528be41 | ||
|
|
fd9084ea6a | ||
|
|
cacc2d311e | ||
|
|
e8f83a237d | ||
|
|
9e1adfed81 | ||
|
|
73801ce864 | ||
|
|
495855cc07 | ||
|
|
05b79aae89 | ||
|
|
d8f9e357d0 | ||
|
|
20bce48132 | ||
|
|
3f27b7e5f7 | ||
|
|
9f74cb32ea | ||
|
|
e333b4d348 | ||
|
|
fb3a0e7b8f | ||
|
|
af49e8cc41 | ||
|
|
4e79d720df | ||
|
|
1c5279747b | ||
|
|
5c1e904335 | ||
|
|
d5386bee41 | ||
|
|
83e1217f61 | ||
|
|
f832ada98c | ||
|
|
eca35ded2f | ||
|
|
3672366fea | ||
|
|
33501d5fdc | ||
|
|
716f679914 | ||
|
|
b26efa4940 | ||
|
|
cb96d7de43 | ||
|
|
ba4637a6bd | ||
|
|
c93077f643 | ||
|
|
fc9bf9cd8a | ||
|
|
d81c017224 | ||
|
|
c37f15cf97 | ||
|
|
08fa157ce4 | ||
|
|
5091174da2 | ||
|
|
ae22d6469d | ||
|
|
c99f0249d9 | ||
|
|
3aba2d220b | ||
|
|
e1ec90dcc5 | ||
|
|
665fc69f67 | ||
|
|
c7dd6778ac | ||
|
|
e96b86ec71 | ||
|
|
633a5bab53 | ||
|
|
5d63ca0bab | ||
|
|
0ff1e8d2f7 | ||
|
|
dce3b59c52 | ||
|
|
5d8dea084c | ||
|
|
c1afdb9bd6 | ||
|
|
ceec54b423 | ||
|
|
e9d51f6c96 | ||
|
|
65b32156a8 | ||
|
|
7c55472b78 | ||
|
|
55ecd92722 | ||
|
|
63e82cca78 | ||
|
|
5bebcec2eb | ||
|
|
7c76c25a9c | ||
|
|
b06aeb22dd |
60
.eslintrc.js
60
.eslintrc.js
@@ -1,4 +1,4 @@
|
||||
const { resolve } = require('node:path');
|
||||
const { join } = require('node:path');
|
||||
|
||||
const createPattern = packageName => [
|
||||
{
|
||||
@@ -31,22 +31,6 @@ const createPattern = packageName => [
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'use-current-user.tsx'",
|
||||
// useSession is type unsafe
|
||||
importNames: ['useSession'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'cloud-utils.ts'",
|
||||
importNames: ['signIn', 'signOut'],
|
||||
},
|
||||
{
|
||||
group: ['yjs'],
|
||||
message: 'Do not use this API because it has a bug',
|
||||
importNames: ['mergeUpdates'],
|
||||
},
|
||||
{
|
||||
group: ['@affine/env/constant'],
|
||||
message:
|
||||
@@ -104,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',
|
||||
@@ -146,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',
|
||||
{
|
||||
@@ -179,22 +165,6 @@ const config = {
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'use-current-user.tsx'",
|
||||
// useSession is type unsafe
|
||||
importNames: ['useSession'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'cloud-utils.ts'",
|
||||
importNames: ['signIn', 'signOut'],
|
||||
},
|
||||
{
|
||||
group: ['yjs'],
|
||||
message: 'Do not use this API because it has a bug',
|
||||
importNames: ['mergeUpdates'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -234,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: [
|
||||
{
|
||||
@@ -250,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',
|
||||
@@ -269,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',
|
||||
{
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
18
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -7,6 +7,8 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
Check out this [link](https://github.com/toeverything/AFFiNE/blob/canary/docs/issue-triaging.md)
|
||||
to learn how we manage issues and when your issue will be processed.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -41,6 +43,14 @@ body:
|
||||
- Firefox
|
||||
- Safari
|
||||
- Other
|
||||
- type: checkboxes
|
||||
id: selfhost
|
||||
attributes:
|
||||
label: Are you self-hosting?
|
||||
description: >
|
||||
If you are self-hosting, please check the box and provide information about your setup.
|
||||
options:
|
||||
- label: 'Yes'
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
@@ -53,11 +63,3 @@ body:
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
Tip: You can attach images here
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Are you willing to submit a PR?
|
||||
description: >
|
||||
(Optional) We encourage you to submit a [Pull Request](https://github.com/toeverything/affine/pulls) (PR) to help improve AFFiNE for everyone, especially if you have a good understanding of how to implement a fix or feature.
|
||||
See the AFFiNE [Contributing Guide](https://github.com/toeverything/affine/blob/canary/CONTRIBUTING.md) to get started.
|
||||
options:
|
||||
- label: Yes I'd like to help by submitting a PR!
|
||||
|
||||
2
.github/actions/deploy/deploy.mjs
vendored
2
.github/actions/deploy/deploy.mjs
vendored
@@ -111,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=true`,
|
||||
`--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: {}
|
||||
|
||||
7
.github/helm/affine/templates/ingress.yaml
vendored
7
.github/helm/affine/templates/ingress.yaml
vendored
@@ -60,6 +60,13 @@ spec:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /oauth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
|
||||
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'
|
||||
|
||||
25
.github/workflows/build-selfhost-image.yml
vendored
Normal file
25
.github/workflows/build-selfhost-image.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Build Selfhost Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flavor:
|
||||
description: 'Select distribution to build'
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
id-token: 'write'
|
||||
packages: 'write'
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
name: Build Image
|
||||
uses: ./.github/workflows/build-server-image.yml
|
||||
with:
|
||||
flavor: ${{ github.event.inputs.flavor }}
|
||||
191
.github/workflows/build-server-image.yml
vendored
Normal file
191
.github/workflows/build-server-image.yml
vendored
Normal file
@@ -0,0 +1,191 @@
|
||||
name: Build Images
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
flavor:
|
||||
type: string
|
||||
required: true
|
||||
|
||||
env:
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
id-token: 'write'
|
||||
packages: 'write'
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
name: Build Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/server
|
||||
- name: Build Server
|
||||
run: yarn workspace @affine/server build
|
||||
- name: Upload server dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-web-selfhost:
|
||||
name: Build @affine/web selfhost
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/web --skip-nx-cache
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: false
|
||||
PUBLIC_PATH: '/'
|
||||
SELF_HOSTED: true
|
||||
- name: Download selfhost fonts
|
||||
run: node ./scripts/download-blocksuite-fonts.mjs
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-web
|
||||
path: ./packages/frontend/web/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage:
|
||||
name: Build Storage - ${{ matrix.targets.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
targets:
|
||||
- name: x86_64-unknown-linux-gnu
|
||||
file: storage.node
|
||||
- name: aarch64-unknown-linux-gnu
|
||||
file: storage.arm64.node
|
||||
- name: armv7-unknown-linux-gnueabihf
|
||||
file: storage.armv7.node
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/storage
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: ${{ matrix.targets.name }}
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload ${{ matrix.targets.file }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.targets.file }}
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server
|
||||
- build-web-selfhost
|
||||
- build-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download server dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.arm64.node
|
||||
path: ./packages/backend/storage
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.armv7.node
|
||||
path: .
|
||||
- name: move storage files
|
||||
run: |
|
||||
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
|
||||
mv storage.node ./packages/backend/server/storage.armv7.node
|
||||
- name: Setup env
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
if [ -z "${{ inputs.flavor }}" ]
|
||||
then
|
||||
echo "RELEASE_FLAVOR=canary" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "RELEASE_FLAVOR=${{ inputs.flavor }}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
logout: false
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
# setup node without cache configuration
|
||||
# Prisma cache is not compatible with docker build cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: https://npm.pkg.github.com
|
||||
scope: '@toeverything'
|
||||
|
||||
- name: Download selfhost web artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: selfhost-web
|
||||
path: ./packages/frontend/web/dist
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: |
|
||||
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
|
||||
yarn config set --json supportedArchitectures.libc '["glibc"]'
|
||||
yarn workspaces focus @affine/server --production
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: yarn workspace @affine/server prisma generate
|
||||
|
||||
- name: Build graphql Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
provenance: true
|
||||
file: .github/deployment/node/Dockerfile
|
||||
tags: ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}
|
||||
40
.github/workflows/build-test.yml
vendored
40
.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,17 @@ 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
|
||||
env:
|
||||
DISTRIBUTION: 'desktop'
|
||||
- name: zip web
|
||||
run: tar -czf dist.tar.gz --directory=packages/frontend/electron/dist .
|
||||
- name: Upload web artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
name: web
|
||||
path: dist.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -336,17 +338,11 @@ jobs:
|
||||
env:
|
||||
PGPASSWORD: affine
|
||||
|
||||
- name: Generate prisma client
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server exec prisma generate
|
||||
yarn workspace @affine/server exec prisma db push
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server data-migration run
|
||||
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
@@ -435,17 +431,11 @@ jobs:
|
||||
env:
|
||||
PGPASSWORD: affine
|
||||
|
||||
- name: Generate prisma client
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server exec prisma generate
|
||||
yarn workspace @affine/server exec prisma db push
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server data-migration run
|
||||
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
|
||||
|
||||
- name: ${{ matrix.tests.name }}
|
||||
run: |
|
||||
@@ -497,7 +487,7 @@ jobs:
|
||||
test: true,
|
||||
}
|
||||
needs:
|
||||
- build-core
|
||||
- build-web
|
||||
- build-native
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -528,8 +518,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
|
||||
|
||||
|
||||
5
.github/workflows/deploy-automatically.yml
vendored
5
.github/workflows/deploy-automatically.yml
vendored
@@ -7,6 +7,11 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
dispatch-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
192
.github/workflows/deploy.yml
vendored
192
.github/workflows/deploy.yml
vendored
@@ -13,33 +13,23 @@ on:
|
||||
- stable
|
||||
- internal
|
||||
env:
|
||||
APP_NAME: affine
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
id-token: 'write'
|
||||
packages: 'write'
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
name: Build Server
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/server
|
||||
- name: Build Server
|
||||
run: yarn workspace @affine/server build
|
||||
- name: Upload server dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
if-no-files-found: error
|
||||
build-core:
|
||||
name: Build @affine/core
|
||||
build-server-image:
|
||||
name: Build Server Image
|
||||
uses: ./.github/workflows/build-server-image.yml
|
||||
with:
|
||||
flavor: ${{ github.event.inputs.flavor }}
|
||||
|
||||
build-web:
|
||||
name: Build @affine/web
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
@@ -50,7 +40,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,114 +54,25 @@ 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
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: false
|
||||
PUBLIC_PATH: '/'
|
||||
- name: Download selfhost fonts
|
||||
run: node ./scripts/download-blocksuite-fonts.mjs
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-core
|
||||
path: ./packages/frontend/core/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage:
|
||||
name: Build Storage - ${{ matrix.targets.name }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
targets:
|
||||
- name: x86_64-unknown-linux-gnu
|
||||
file: storage.node
|
||||
- name: aarch64-unknown-linux-gnu
|
||||
file: storage.arm64.node
|
||||
- name: armv7-unknown-linux-gnueabihf
|
||||
file: storage.armv7.node
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/storage
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: ${{ matrix.targets.name }}
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload ${{ matrix.targets.file }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.targets.file }}
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
build-frontend-image:
|
||||
name: Build Frontend Image
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
- build-core-selfhost
|
||||
- build-storage
|
||||
- build-web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download core artifact
|
||||
- name: Download web artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
- name: Download server dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.arm64.node
|
||||
path: ./packages/backend/storage
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.armv7.node
|
||||
path: .
|
||||
- name: move storage files
|
||||
run: |
|
||||
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
|
||||
mv storage.node ./packages/backend/server/storage.armv7.node
|
||||
name: web
|
||||
path: ./packages/frontend/web/dist
|
||||
- name: Setup env
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
@@ -181,7 +82,6 @@ jobs:
|
||||
else
|
||||
echo "RELEASE_FLAVOR=${{ inputs.flavor }}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -204,53 +104,13 @@ jobs:
|
||||
file: .github/deployment/front/Dockerfile
|
||||
tags: ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}
|
||||
|
||||
# setup node without cache configuration
|
||||
# Prisma cache is not compatible with docker build cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: https://npm.pkg.github.com
|
||||
scope: '@toeverything'
|
||||
|
||||
- name: Remove core dist
|
||||
run: rm -rf ./packages/frontend/core/dist
|
||||
|
||||
- name: Download selfhost core artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: selfhost-core
|
||||
path: ./packages/frontend/core/dist
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: |
|
||||
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
|
||||
yarn config set --json supportedArchitectures.libc '["glibc"]'
|
||||
yarn workspaces focus @affine/server --production
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: yarn workspace @affine/server prisma generate
|
||||
|
||||
- name: Build graphql Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
provenance: true
|
||||
file: .github/deployment/node/Dockerfile
|
||||
tags: ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}
|
||||
|
||||
deploy:
|
||||
name: Deploy to cluster
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
permissions:
|
||||
contents: 'write'
|
||||
id-token: 'write'
|
||||
needs:
|
||||
- build-docker
|
||||
- build-frontend-image
|
||||
- build-server-image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -7,6 +7,11 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
dispatch-release-desktop:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
25
.github/workflows/release-desktop.yml
vendored
25
.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:
|
||||
@@ -89,6 +90,10 @@ jobs:
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
@@ -110,7 +115,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
|
||||
@@ -168,6 +173,10 @@ jobs:
|
||||
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
|
||||
env:
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
@@ -188,7 +197,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
|
||||
@@ -278,7 +287,7 @@ jobs:
|
||||
artifact-name: installer-win32-x64
|
||||
|
||||
finalize-installer-windows:
|
||||
needs: sign-installer-artifacts-windows
|
||||
needs: [sign-installer-artifacts-windows, before-make]
|
||||
strategy:
|
||||
matrix:
|
||||
spec:
|
||||
@@ -317,7 +326,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
|
||||
@@ -351,7 +360,7 @@ jobs:
|
||||
RELEASE_VERSION: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
- name: Create Release Draft
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
body: ''
|
||||
@@ -367,7 +376,7 @@ jobs:
|
||||
./*.yml
|
||||
- name: Create Nightly Release Draft
|
||||
if: ${{ github.ref_type == 'branch' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
|
||||
18
README.md
18
README.md
@@ -10,7 +10,7 @@
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
|
||||
A privacy-focused, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
|
||||
One hyper-fused platform for wildly creative minds.
|
||||
</p>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
## Getting started & staying tuned with us.
|
||||
|
||||
Star us, and you will receive all releases notifications from GitHub without any delay!
|
||||
Star us, and you will receive all release notifications from GitHub without any delay!
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif" style="width: 100%"/>
|
||||
|
||||
@@ -65,7 +65,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
|
||||
|
||||
**Multimodal AI partner ready to kick in any work**
|
||||
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or....draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
|
||||
|
||||
**Local-first & Real-time collaborative**
|
||||
|
||||
@@ -73,7 +73,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
|
||||
|
||||
**Self-host & Shape your own AFFiNE**
|
||||
|
||||
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks is coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
|
||||
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks are coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
@@ -83,7 +83,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
|
||||
- Trello with their Kanban
|
||||
- Airtable & Miro with their no-code programable datasheets
|
||||
- Miro & Whimiscal with their edgeless visual whiteboard
|
||||
- Remnote & Capacities with their object-based tag system
|
||||
- Remote & Capacities with their object-based tag system
|
||||
|
||||
There is a large overlap of their atomic “building blocks” between these apps. They are not open source, nor do they have a plugin system like Vscode for contributors to customize. We want to have something that contains all the features we love and also goes one step even further.
|
||||
|
||||
@@ -104,7 +104,7 @@ For **bug reports**, **feature requests** and other **suggestions** you can also
|
||||
|
||||
For **translation** and **language support** you can visit our [i18n General Space](https://community.affine.pro/c/i18n-general).
|
||||
|
||||
Looking for **others ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide-range of support and resources.
|
||||
Looking for **other ways to contribute** and wondering where to start? Check out the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador), we work closely with passionate community members and provide them with a wide range of support and resources.
|
||||
|
||||
If you have questions, you are welcome to contact us. One of the best places to get more info and learn more is in the [AFFiNE Community](https://community.affine.pro) where you can engage with other like-minded individuals.
|
||||
|
||||
@@ -147,11 +147,11 @@ Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiN
|
||||
|
||||
## Hiring
|
||||
|
||||
Some amazing companies including AFFiNE are looking for developers! Are you interesgo to iour discord channel AFFiNE and/or its partners? Check out some of the latest [jobs available].
|
||||
Some amazing companies, including AFFiNE, are looking for developers! Are you interested in joining AFFiNE or its partners? Check out our Discord channel for some of the latest jobs available.
|
||||
|
||||
## Feature Request
|
||||
|
||||
For feature request, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
|
||||
For feature requests, please see [community.affine.pro](https://community.affine.pro/c/feature-requests/).
|
||||
|
||||
## Building
|
||||
|
||||
@@ -186,7 +186,7 @@ See [LICENSE] for details.
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.76.0-dea584
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.77.0-dea584
|
||||
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
|
||||
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
|
||||
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
|
||||
|
||||
@@ -6,8 +6,8 @@ We recommend users to always use the latest major version. Security updates will
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.12.x (stable) | :white_check_mark: |
|
||||
| < 0.12.x | :x: |
|
||||
| 0.13.x (stable) | :white_check_mark: |
|
||||
| < 0.13.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -49,28 +49,24 @@ postgres=# \du
|
||||
|
||||
### Set the following config to `packages/backend/server/.env`
|
||||
|
||||
In the following setup, we assume you have postgres server running at localhost:5432 and mailhog running at localhost:1025.
|
||||
|
||||
When logging in via email, you will see the mail arriving at localhost:8025 in a browser.
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://affine:affine@localhost:5432/affine"
|
||||
NEXTAUTH_URL="http://localhost:8080/"
|
||||
```
|
||||
|
||||
You may need additional env for auth login. You may want to put your own one if you are not part of the AFFiNE team
|
||||
|
||||
For email login & password, please refer to https://nodemailer.com/usage/using-gmail/
|
||||
|
||||
```
|
||||
MAILER_SENDER=
|
||||
MAILER_USER=
|
||||
MAILER_PASSWORD=
|
||||
OAUTH_GOOGLE_ENABLED="true"
|
||||
OAUTH_GOOGLE_CLIENT_ID=
|
||||
OAUTH_GOOGLE_CLIENT_SECRET=
|
||||
MAILER_SENDER="noreply@toeverything.info"
|
||||
MAILER_USER="auth"
|
||||
MAILER_PASSWORD="auth"
|
||||
MAILER_HOST="localhost"
|
||||
MAILER_PORT="1025"
|
||||
```
|
||||
|
||||
## 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"
|
||||
}
|
||||
|
||||
35
package.json
35
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.7",
|
||||
"@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",
|
||||
@@ -103,9 +105,9 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-istanbul": "^5.0.0",
|
||||
"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"
|
||||
},
|
||||
@@ -167,9 +169,8 @@
|
||||
"unbox-primitive": "npm:@nolyfill/unbox-primitive@latest",
|
||||
"which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
|
||||
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
|
||||
"next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch",
|
||||
"@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,70 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "sessions" DROP CONSTRAINT "sessions_user_id_fkey";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_connected_accounts" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"provider_account_id" VARCHAR NOT NULL,
|
||||
"scope" TEXT,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_connected_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "multiple_users_sessions" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "multiple_users_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"session_id" VARCHAR(36) NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"token" VARCHAR(36) NOT NULL,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"credential" TEXT,
|
||||
"expiresAt" TIMESTAMPTZ(6) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_connected_accounts_user_id_idx" ON "user_connected_accounts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_connected_accounts_provider_account_id_idx" ON "user_connected_accounts"("provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_sessions_session_id_user_id_key" ON "user_sessions"("session_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_type_token_key" ON "verification_tokens"("type", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_connected_accounts" ADD CONSTRAINT "user_connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "multiple_users_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -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",
|
||||
@@ -45,11 +45,11 @@
|
||||
"@opentelemetry/exporter-zipkin": "^1.21.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.0",
|
||||
"@opentelemetry/instrumentation": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.38.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.38.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.35.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.37.0",
|
||||
"@opentelemetry/resources": "^1.21.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||
"@opentelemetry/sdk-node": "^0.49.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,10 +71,10 @@
|
||||
"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",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.10",
|
||||
"on-headers": "^1.0.2",
|
||||
"parse-duration": "^1.1.0",
|
||||
@@ -103,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",
|
||||
@@ -143,7 +144,8 @@
|
||||
"MAILER_USER": "noreply@toeverything.info",
|
||||
"MAILER_PASSWORD": "affine",
|
||||
"MAILER_SENDER": "noreply@toeverything.info",
|
||||
"FEATURES_EARLY_ACCESS_PREVIEW": "false"
|
||||
"FEATURES_EARLY_ACCESS_PREVIEW": "false",
|
||||
"DEPLOYMENT_TYPE": "affine"
|
||||
}
|
||||
},
|
||||
"nodemonConfig": {
|
||||
|
||||
@@ -10,28 +10,83 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
// image field is for the next-auth
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String
|
||||
email String @unique
|
||||
emailVerifiedAt DateTime? @map("email_verified")
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
/// Not available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
password String? @db.VarChar
|
||||
/// Indicate whether the user finished the signup progress.
|
||||
/// for example, the value will be false if user never registered and invited into a workspace by others.
|
||||
registered Boolean @default(true)
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
features UserFeatures[]
|
||||
customer UserStripeCustomer?
|
||||
subscription UserSubscription?
|
||||
subscriptions UserSubscription[]
|
||||
invoices UserInvoice[]
|
||||
workspacePermissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
provider String @db.VarChar
|
||||
providerAccountId String @map("provider_account_id") @db.VarChar
|
||||
scope String? @db.Text
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([providerAccountId])
|
||||
@@map("user_connected_accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
userSessions UserSession[]
|
||||
|
||||
@@map("multiple_users_sessions")
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
sessionId String @map("session_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, userId])
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @db.VarChar(36)
|
||||
type Int @db.SmallInt
|
||||
credential String? @db.Text
|
||||
expiresAt DateTime @db.Timestamptz(6)
|
||||
|
||||
@@unique([type, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
@@ -186,7 +241,7 @@ model Features {
|
||||
@@map("features")
|
||||
}
|
||||
|
||||
model Account {
|
||||
model DeprecatedNextAuthAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
@@ -200,23 +255,20 @@ model Account {
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
model DeprecatedNextAuthSession {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
model DeprecatedNextAuthVerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
@@ -317,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)
|
||||
@@ -343,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,37 +0,0 @@
|
||||
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
...userA,
|
||||
password: await hash(userA.password),
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by api sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature: 'free_plan_v1',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { Public } from './core/auth';
|
||||
import { Config } from './fundamentals/config';
|
||||
|
||||
@Controller('/')
|
||||
export class AppController {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
info() {
|
||||
return {
|
||||
|
||||
@@ -1,50 +1,49 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { get } from 'lodash-es';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { AuthGuard, AuthModule } from './core/auth';
|
||||
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
|
||||
import { DocModule } from './core/doc';
|
||||
import { FeatureModule } from './core/features';
|
||||
import { QuotaModule } from './core/quota';
|
||||
import { StorageModule } from './core/storage';
|
||||
import { SyncModule } from './core/sync';
|
||||
import { UsersModule } from './core/users';
|
||||
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';
|
||||
import { MailModule } from './fundamentals/mailer';
|
||||
import { MetricsModule } from './fundamentals/metrics';
|
||||
import { MutexModule } from './fundamentals/mutex';
|
||||
import { PrismaModule } from './fundamentals/prisma';
|
||||
import { SessionModule } from './fundamentals/session';
|
||||
import { StorageProviderModule } from './fundamentals/storage';
|
||||
import { RateLimiterModule } from './fundamentals/throttler';
|
||||
import { WebSocketModule } from './fundamentals/websocket';
|
||||
import { pluginsMap } from './plugins';
|
||||
import { REGISTERED_PLUGINS } from './plugins';
|
||||
|
||||
export const FunctionalityModules = [
|
||||
ConfigModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
EventModule,
|
||||
CacheModule,
|
||||
MutexModule,
|
||||
PrismaModule,
|
||||
MetricsModule,
|
||||
RateLimiterModule,
|
||||
SessionModule,
|
||||
MailModule,
|
||||
StorageProviderModule,
|
||||
HelpersModule,
|
||||
];
|
||||
|
||||
export class AppModuleBuilder {
|
||||
@@ -109,6 +108,10 @@ export class AppModuleBuilder {
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: CacheInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
],
|
||||
imports: this.modules,
|
||||
controllers: this.config.isSelfhosted ? [] : [AppController],
|
||||
@@ -141,7 +144,7 @@ function buildAppModule() {
|
||||
WebSocketModule,
|
||||
GqlModule,
|
||||
StorageModule,
|
||||
UsersModule,
|
||||
UserModule,
|
||||
WorkspaceModule,
|
||||
FeatureModule,
|
||||
QuotaModule
|
||||
@@ -157,7 +160,7 @@ function buildAppModule() {
|
||||
|
||||
// plugin modules
|
||||
AFFiNE.plugins.enabled.forEach(name => {
|
||||
const plugin = pluginsMap.get(name as AvailablePlugins);
|
||||
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin ${name}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ AFFiNE.ENV_MAP = {
|
||||
DATABASE_URL: 'db.url',
|
||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
|
||||
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
|
||||
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
|
||||
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
|
||||
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
|
||||
OAUTH_GOOGLE_CLIENT_ID: 'plugins.oauth.providers.google.clientId',
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret',
|
||||
OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId',
|
||||
OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret',
|
||||
MAILER_HOST: 'mailer.host',
|
||||
MAILER_PORT: ['mailer.port', 'int'],
|
||||
MAILER_USER: 'mailer.auth.user',
|
||||
|
||||
@@ -39,7 +39,16 @@ 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) {
|
||||
AFFiNE.mailer = {
|
||||
|
||||
@@ -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 */
|
||||
@@ -115,3 +127,27 @@ AFFiNE.plugins.use('payment', {
|
||||
// /* Update the provider of storages */
|
||||
// AFFiNE.storage.storages.blob.provider = 'r2';
|
||||
// AFFiNE.storage.storages.avatar.provider = 'r2';
|
||||
//
|
||||
// /* OAuth Plugin */
|
||||
// AFFiNE.plugins.use('oauth', {
|
||||
// providers: {
|
||||
// github: {
|
||||
// clientId: '',
|
||||
// clientSecret: '',
|
||||
// // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
// args: {
|
||||
// scope: 'user',
|
||||
// },
|
||||
// },
|
||||
// google: {
|
||||
// clientId: '',
|
||||
// clientSecret: '',
|
||||
// args: {
|
||||
// // See https://developers.google.com/identity/protocols/oauth2
|
||||
// scope: 'openid email profile',
|
||||
// promot: 'select_account',
|
||||
// access_type: 'offline',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
193
packages/backend/server/src/core/auth/controller.ts
Normal file
193
packages/backend/server/src/core/auth/controller.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
|
||||
import { UserService } from '../user';
|
||||
import { validators } from '../utils/validators';
|
||||
import { CurrentUser } from './current-user';
|
||||
import { Public } from './guard';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
class SignInCredential {
|
||||
email!: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly token: TokenService
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('/sign-in')
|
||||
@Header('content-type', 'application/json')
|
||||
async signIn(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() credential: SignInCredential,
|
||||
@Query('redirect_uri') redirectUri = this.url.home
|
||||
) {
|
||||
validators.assertValidEmail(credential.email);
|
||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||
if (!canSignIn) {
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
);
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
const user = await this.auth.signIn(
|
||||
credential.email,
|
||||
credential.password
|
||||
);
|
||||
|
||||
await this.auth.setCookie(req, res, user);
|
||||
res.status(HttpStatus.OK).send(user);
|
||||
} else {
|
||||
// send email magic link
|
||||
const user = await this.user.findUserByEmail(credential.email);
|
||||
const result = await this.sendSignInEmail(
|
||||
{ email: credential.email, signUp: !user },
|
||||
redirectUri
|
||||
);
|
||||
|
||||
if (result.rejected.length) {
|
||||
throw new Error('Failed to send sign-in email.');
|
||||
}
|
||||
|
||||
res.status(HttpStatus.OK).send({
|
||||
email: credential.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendSignInEmail(
|
||||
{ email, signUp }: { email: string; signUp: boolean },
|
||||
redirectUri: string
|
||||
) {
|
||||
const token = await this.token.createToken(TokenType.SignIn, email);
|
||||
|
||||
const magicLink = this.url.link('/api/auth/magic-link', {
|
||||
token,
|
||||
email,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const result = await this.auth.sendSignInEmail(email, magicLink, signUp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('/sign-out')
|
||||
async signOut(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('redirect_uri') redirectUri?: string
|
||||
) {
|
||||
const session = await this.auth.signOut(
|
||||
req.cookies[AuthService.sessionCookieName],
|
||||
parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName])
|
||||
);
|
||||
|
||||
if (session) {
|
||||
res.cookie(AuthService.sessionCookieName, session.id, {
|
||||
expires: session.expiresAt ?? void 0, // expiredAt is `string | null`
|
||||
...this.auth.cookieOptions,
|
||||
});
|
||||
} else {
|
||||
res.clearCookie(AuthService.sessionCookieName);
|
||||
}
|
||||
|
||||
if (redirectUri) {
|
||||
return this.url.safeRedirect(res, redirectUri);
|
||||
} else {
|
||||
return res.send(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/magic-link')
|
||||
async magicLinkSignIn(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('token') token?: string,
|
||||
@Query('email') email?: string,
|
||||
@Query('redirect_uri') redirectUri = this.url.home
|
||||
) {
|
||||
if (!token || !email) {
|
||||
throw new BadRequestException('Invalid Sign-in mail Token');
|
||||
}
|
||||
|
||||
email = decodeURIComponent(email);
|
||||
token = decodeURIComponent(token);
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
|
||||
credential: email,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Invalid Sign-in mail Token');
|
||||
}
|
||||
|
||||
const user = await this.user.fulfillUser(email, {
|
||||
emailVerifiedAt: new Date(),
|
||||
registered: true,
|
||||
});
|
||||
|
||||
await this.auth.setCookie(req, res, user);
|
||||
|
||||
return this.url.safeRedirect(res, redirectUri);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/session')
|
||||
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
|
||||
return {
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/sessions')
|
||||
async currentSessionUsers(@Req() req: Request) {
|
||||
const token = req.cookies[AuthService.sessionCookieName];
|
||||
if (!token) {
|
||||
return {
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
users: await this.auth.getUserList(token),
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/challenge')
|
||||
async challenge() {
|
||||
// TODO: impl in following PR
|
||||
return {
|
||||
challenge: randomUUID(),
|
||||
resource: randomUUID(),
|
||||
};
|
||||
}
|
||||
}
|
||||
55
packages/backend/server/src/core/auth/current-user.ts
Normal file
55
packages/backend/server/src/core/auth/current-user.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
|
||||
function getUserFromContext(context: ExecutionContext) {
|
||||
return getRequestResponseFromContext(context).req.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token or session cookie is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user')
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // for public apis
|
||||
* \@Public()
|
||||
* \@Get('/session')
|
||||
* session(@currentUser() user?: CurrentUser) {
|
||||
* return user
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// interface and variable don't conflict
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
export interface CurrentUser
|
||||
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name'> {
|
||||
hasPassword: boolean | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
@@ -1,67 +1,62 @@
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
createParamDecorator,
|
||||
Inject,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
return getRequestResponseFromContext(context).req.user;
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
if (!/^Bearer\s/i.test(authorization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authorization.substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
|
||||
constructor(
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly auth: AuthService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly ref: ModuleRef,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.auth = this.ref.get(AuthService, { strict: false });
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
|
||||
// check cookie
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const user = await this.auth.getUser(sessionToken, userSeq);
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.get<boolean>(
|
||||
@@ -69,63 +64,15 @@ class AuthGuard implements CanActivate {
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
// FIXME(@forehalo): @Publicable() is duplicated with @CurrentUser() user?: User
|
||||
// ^ optional
|
||||
// we can prefetch user session in each request even before this `Guard`
|
||||
// api can be public, but if user is logged in, we can get user info
|
||||
const isPublicable = this.reflector.get<boolean>(
|
||||
'isPublicable',
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
} else if (!token) {
|
||||
if (!req.cookies) {
|
||||
return isPublicable;
|
||||
}
|
||||
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
|
||||
const { body = {}, cookies, status = 200 } = session;
|
||||
if (!body && !isPublicable) {
|
||||
throw new UnauthorizedException('You are not signed in.');
|
||||
}
|
||||
|
||||
// @ts-expect-error body is user here
|
||||
req.user = body.user;
|
||||
if (cookies && res) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
status === 200 &&
|
||||
typeof body !== 'string' &&
|
||||
// ignore body if api is publicable
|
||||
(Object.keys(body).length || isPublicable)
|
||||
);
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.auth.verify(jwt);
|
||||
req.user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException('You are not signed in.');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +87,7 @@ class AuthGuard implements CanActivate {
|
||||
* ```typescript
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: User) {
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
@@ -151,5 +98,3 @@ export const Auth = () => {
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
// api is public accessible, but if user is logged in, we can get user info
|
||||
export const Publicable = () => SetMetadata('isPublicable', true);
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { NextAuthController } from './next-auth.controller';
|
||||
import { NextAuthOptionsProvider } from './next-auth-options';
|
||||
import { FeatureModule } from '../features';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthController } from './controller';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
|
||||
exports: [AuthService, NextAuthOptionsProvider],
|
||||
controllers: [NextAuthController],
|
||||
imports: [FeatureModule, UserModule],
|
||||
providers: [AuthService, AuthResolver, TokenService],
|
||||
exports: [AuthService],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
export * from './guard';
|
||||
export { TokenType } from './resolver';
|
||||
export { AuthService };
|
||||
export { ClientTokenType } from './resolver';
|
||||
export { AuthService, TokenService, TokenType };
|
||||
export * from './current-user';
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import Email from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config, MailService, SessionService } from '../../fundamentals';
|
||||
import { FeatureType } from '../features';
|
||||
import { Quota_FreePlanV1_1 } from '../quota';
|
||||
import {
|
||||
decode,
|
||||
encode,
|
||||
sendVerificationRequest,
|
||||
SendVerificationRequestParams,
|
||||
} from './utils';
|
||||
|
||||
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||
|
||||
const TrustedProviders = ['google'];
|
||||
|
||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
provide: NextAuthOptionsProvide,
|
||||
useFactory(
|
||||
config: Config,
|
||||
prisma: PrismaClient,
|
||||
mailer: MailService,
|
||||
session: SessionService
|
||||
) {
|
||||
const logger = new Logger('NextAuth');
|
||||
const prismaAdapter = PrismaAdapter(prisma);
|
||||
// createUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
|
||||
prismaAdapter.createUser = async data => {
|
||||
const userData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
avatarUrl: '',
|
||||
emailVerified: data.emailVerified,
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by email sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (data.email && !data.name) {
|
||||
userData.name = data.email.split('@')[0];
|
||||
}
|
||||
if (data.image) {
|
||||
userData.avatarUrl = data.image;
|
||||
}
|
||||
// @ts-expect-error third part library type mismatch
|
||||
return createUser(userData);
|
||||
};
|
||||
// linkAccount exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter);
|
||||
prismaAdapter.linkAccount = async account => {
|
||||
// google account must be a verified email
|
||||
if (TrustedProviders.includes(account.provider)) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: account.userId,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return linkAccount(account) as Promise<void>;
|
||||
};
|
||||
// getUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
|
||||
prismaAdapter.getUser = async id => {
|
||||
const result = await getUser(id);
|
||||
if (result) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.image = result.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.hasPassword = Boolean(result.password);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
prismaAdapter.createVerificationToken = async data => {
|
||||
await session.set(
|
||||
`${data.identifier}:${data.token}`,
|
||||
Date.now() + session.sessionTtl
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
|
||||
const expires = await session.get(`${identifier}:${token}`);
|
||||
if (expires) {
|
||||
return { identifier, token, expires: new Date(expires) };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const nextAuthOptions: NextAuthOptions = {
|
||||
providers: [],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.node.prod,
|
||||
logger: {
|
||||
debug(code, metadata) {
|
||||
logger.debug(`${code}: ${JSON.stringify(metadata)}`);
|
||||
},
|
||||
error(code, metadata) {
|
||||
if (metadata instanceof Error) {
|
||||
// @ts-expect-error assign code to error
|
||||
metadata.code = code;
|
||||
logger.error(metadata);
|
||||
} else if (metadata.error instanceof Error) {
|
||||
assign(metadata.error, omit(metadata, 'error'), { code });
|
||||
logger.error(metadata.error);
|
||||
}
|
||||
},
|
||||
warn(code) {
|
||||
logger.warn(code);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Credentials.default({
|
||||
name: 'Password',
|
||||
credentials: {
|
||||
email: {
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
placeholder: 'torvalds@osdl.org',
|
||||
},
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(
|
||||
credentials:
|
||||
| Record<'email' | 'password' | 'hashedPassword', string>
|
||||
| undefined
|
||||
) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
const { password, hashedPassword } = credentials;
|
||||
if (!password || !hashedPassword) {
|
||||
return null;
|
||||
}
|
||||
if (!(await verify(hashedPassword, password))) {
|
||||
return null;
|
||||
}
|
||||
return credentials;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (config.mailer && mailer) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Email.default({
|
||||
sendVerificationRequest: (params: SendVerificationRequestParams) =>
|
||||
sendVerificationRequest(config, logger, mailer, session, params),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Github.default({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google?.enabled) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Google.default({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
checks: 'nonce',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
authorization: {
|
||||
params: { scope: 'openid email profile', prompt: 'select_account' },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (nextAuthOptions.providers.length > 1) {
|
||||
// not only credentials provider
|
||||
nextAuthOptions.session = { strategy: 'database' };
|
||||
}
|
||||
|
||||
nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) =>
|
||||
encode(config, prisma, token, maxAge),
|
||||
decode: async ({ token }) => decode(config, token),
|
||||
};
|
||||
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
|
||||
nextAuthOptions.callbacks = {
|
||||
session: async ({ session, user, token }) => {
|
||||
if (session.user) {
|
||||
if (user) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = user.id;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.image = user.image ?? user.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = user.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = Boolean(user.password);
|
||||
} else {
|
||||
// technically the sub should be the same as id
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = token.sub;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = token.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = token.hasPassword;
|
||||
}
|
||||
if (token && token.picture) {
|
||||
session.user.image = token.picture;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
signIn: async ({ profile, user }) => {
|
||||
if (!config.featureFlags.earlyAccessPreview) {
|
||||
return true;
|
||||
}
|
||||
const email = profile?.email ?? user.email;
|
||||
if (email) {
|
||||
// FIXME: cannot inject FeatureManagementService here
|
||||
// it will cause prisma.account to be undefined
|
||||
// then prismaAdapter.getUserByAccount will throw error
|
||||
if (email.endsWith('@toeverything.info')) return true;
|
||||
return prisma.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
user: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
redirect({ url }) {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.pages = {
|
||||
newUser: '/auth/onboarding',
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaClient, MailService, SessionService],
|
||||
};
|
||||
@@ -1,411 +0,0 @@
|
||||
import { URLSearchParams } from 'node:url';
|
||||
|
||||
import {
|
||||
All,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Logger,
|
||||
Next,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { pick } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import {
|
||||
AuthThrottlerGuard,
|
||||
Config,
|
||||
metrics,
|
||||
SessionService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
const BASE_URL = '/api/auth/';
|
||||
|
||||
const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days
|
||||
|
||||
@Controller(BASE_URL)
|
||||
export class NextAuthController {
|
||||
private readonly callbackSession;
|
||||
|
||||
private readonly logger = new Logger('NextAuthController');
|
||||
|
||||
constructor(
|
||||
readonly config: Config,
|
||||
readonly prisma: PrismaClient,
|
||||
private readonly authService: AuthService,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly session: SessionService
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 60,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Get('/challenge')
|
||||
async getChallenge(@Res() res: Response) {
|
||||
const challenge = nanoid();
|
||||
const resource = nanoid();
|
||||
await this.session.set(challenge, resource, 5 * 60 * 1000);
|
||||
res.json({ challenge, resource });
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 60,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@All('*')
|
||||
async auth(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query() query: Record<string, any>,
|
||||
@Next() next: NextFunction
|
||||
) {
|
||||
if (req.path === '/api/auth/signin' && req.method === 'GET') {
|
||||
const query = req.query
|
||||
? // @ts-expect-error req.query is satisfy with the Record<string, any>
|
||||
`?${new URLSearchParams(req.query).toString()}`
|
||||
: '';
|
||||
res.redirect(`/signin${query}`);
|
||||
return;
|
||||
}
|
||||
const [action, providerId] = req.url // start with request url
|
||||
.slice(BASE_URL.length) // make relative to baseUrl
|
||||
.replace(/\?.*/, '') // remove query part, use only path part
|
||||
.split('/') as [AuthAction, string]; // as array of strings;
|
||||
|
||||
metrics.auth.counter('call_counter').add(1, { action, providerId });
|
||||
|
||||
const credentialsSignIn =
|
||||
req.method === 'POST' && providerId === 'credentials';
|
||||
let userId: string | undefined;
|
||||
if (credentialsSignIn) {
|
||||
const { email } = req.body;
|
||||
if (email) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
req.statusCode = 401;
|
||||
req.statusMessage = 'User not found';
|
||||
req.body = null;
|
||||
throw new NotFoundException(`User not found`);
|
||||
} else {
|
||||
userId = user.id;
|
||||
req.body = {
|
||||
...req.body,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatarUrl,
|
||||
hashedPassword: user.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const options = this.nextAuthOptions;
|
||||
if (req.method === 'POST' && action === 'session') {
|
||||
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'invalid_session_data' });
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
const user = await this.updateSession(req, req.body.data);
|
||||
// callbacks.session existed
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = ({ session }) => {
|
||||
return {
|
||||
user: {
|
||||
...pick(user, 'id', 'name', 'email'),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: !!user.password,
|
||||
},
|
||||
expires: session.expires,
|
||||
};
|
||||
};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = this.callbackSession;
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.auth.captcha.enable &&
|
||||
req.method === 'POST' &&
|
||||
action === 'signin' &&
|
||||
// TODO: add credentials support in frontend
|
||||
['email'].includes(providerId)
|
||||
) {
|
||||
const isVerified = await this.verifyChallenge(req, res);
|
||||
if (!isVerified) return;
|
||||
}
|
||||
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
query: query,
|
||||
method: req.method,
|
||||
action,
|
||||
providerId,
|
||||
error: query.error ?? providerId,
|
||||
cookies: req.cookies,
|
||||
},
|
||||
options,
|
||||
});
|
||||
|
||||
if (headers) {
|
||||
for (const { key, value } of headers) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
if (cookies) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined;
|
||||
const secureCookiePrefix = '__Secure-';
|
||||
const sessionCookieName = `next-auth.session-token`;
|
||||
// next-auth credentials login only support JWT strategy
|
||||
// https://next-auth.js.org/configuration/providers/credentials
|
||||
// let's store the session token in the database
|
||||
if (
|
||||
credentialsSignIn &&
|
||||
(nextAuthTokenCookie = cookies?.find(
|
||||
({ name }) =>
|
||||
name === sessionCookieName ||
|
||||
name === `${secureCookiePrefix}${sessionCookieName}`
|
||||
))
|
||||
) {
|
||||
const cookieExpires = new Date();
|
||||
cookieExpires.setTime(
|
||||
cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await this.nextAuthOptions.adapter!.createSession!({
|
||||
sessionToken: nextAuthTokenCookie.value,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
userId: userId!,
|
||||
expires: cookieExpires,
|
||||
});
|
||||
}
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
this.logger.log(`Early access redirect headers: ${req.headers}`);
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'no_early_access_permission' });
|
||||
|
||||
if (
|
||||
!req.headers?.referer ||
|
||||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
|
||||
) {
|
||||
res.redirect('https://community.affine.pro/c/insider-general/');
|
||||
} else {
|
||||
res.status(403);
|
||||
res.json({
|
||||
url: 'https://community.affine.pro/c/insider-general/',
|
||||
error: `You don't have early access permission`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
if (providerId === 'credentials') {
|
||||
res.send(JSON.stringify({ ok: true, url: redirect }));
|
||||
} else if (
|
||||
action === 'callback' ||
|
||||
action === 'error' ||
|
||||
(providerId !== 'credentials' &&
|
||||
// login in the next-auth page, /api/auth/signin, auto redirect.
|
||||
// otherwise, return the json value to allow frontend to handle the redirect.
|
||||
req.headers?.referer?.includes?.('/api/auth/signin'))
|
||||
) {
|
||||
res.redirect(redirect);
|
||||
} else {
|
||||
res.json({ url: redirect });
|
||||
}
|
||||
} else if (typeof body === 'string') {
|
||||
res.send(body);
|
||||
} else if (body && typeof body === 'object') {
|
||||
res.json(body);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSession(
|
||||
req: Request,
|
||||
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
|
||||
): Promise<User> {
|
||||
const { name, email, password, oldPassword } = newSession;
|
||||
if (!name && !email && !password) {
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
if (password) {
|
||||
const user = await this.verifyUserFromRequest(req);
|
||||
const { password: userPassword } = user;
|
||||
if (!oldPassword) {
|
||||
if (userPassword) {
|
||||
throw new BadRequestException(
|
||||
`Old password is required to update password`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!userPassword) {
|
||||
throw new BadRequestException(`No existed password`);
|
||||
}
|
||||
if (await verify(userPassword, oldPassword)) {
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
...pick(newSession, 'email', 'name'),
|
||||
password: await hash(password),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return user;
|
||||
} else {
|
||||
const user = await this.verifyUserFromRequest(req);
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: pick(newSession, 'name', 'email'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
|
||||
const challenge = req.query?.challenge;
|
||||
if (typeof challenge === 'string' && challenge) {
|
||||
const resource = await this.session.get(challenge);
|
||||
|
||||
if (!resource) {
|
||||
this.rejectResponse(res, 'Invalid Challenge');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChallengeVerified =
|
||||
await this.authService.verifyChallengeResponse(
|
||||
req.query?.token,
|
||||
resource
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
|
||||
);
|
||||
|
||||
if (!isChallengeVerified) {
|
||||
this.rejectResponse(res, 'Invalid Challenge Response');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const isTokenVerified = await this.authService.verifyCaptchaToken(
|
||||
req.query?.token,
|
||||
req.headers['CF-Connecting-IP'] as string
|
||||
);
|
||||
|
||||
if (!isTokenVerified) {
|
||||
this.rejectResponse(res, 'Invalid Captcha Response');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async verifyUserFromRequest(req: Request): Promise<User> {
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
|
||||
const { body } = session;
|
||||
// @ts-expect-error check if body.user exists
|
||||
if (body && body.user && body.user.id) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
// @ts-expect-error body.user.id exists
|
||||
id: body.user.id,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.authService.verify(jwt);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
|
||||
rejectResponse(res: Response, error: string, status = 400) {
|
||||
res.status(status);
|
||||
res.json({
|
||||
url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({
|
||||
error,
|
||||
}).toString()}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkUrlOrigin = (url: string, origin: string) => {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -10,24 +10,23 @@ import {
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
Config,
|
||||
SessionService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { UserType } from '../users';
|
||||
import { Auth, CurrentUser } from './guard';
|
||||
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';
|
||||
import { Public } from './guard';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@ObjectType()
|
||||
export class TokenType {
|
||||
@ObjectType('tokenType')
|
||||
export class ClientTokenType {
|
||||
@Field()
|
||||
token!: string;
|
||||
|
||||
@@ -50,46 +49,58 @@ export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly auth: AuthService,
|
||||
private readonly session: SessionService
|
||||
private readonly user: UserService,
|
||||
private readonly token: TokenService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Public()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@ResolveField(() => TokenType)
|
||||
async token(
|
||||
@Context() ctx: { req: Request },
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@ResolveField(() => ClientTokenType, {
|
||||
name: 'token',
|
||||
deprecationReason: 'use [/api/auth/authorize]',
|
||||
})
|
||||
async clientToken(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Parent() user: UserType
|
||||
) {
|
||||
): Promise<ClientTokenType> {
|
||||
if (user.id !== currentUser.id) {
|
||||
throw new BadRequestException('Invalid user');
|
||||
throw new ForbiddenException('Invalid user');
|
||||
}
|
||||
|
||||
let sessionToken: string | undefined;
|
||||
|
||||
// only return session if the request is from the same origin & path == /open-app
|
||||
if (
|
||||
ctx.req.headers.referer &&
|
||||
ctx.req.headers.host &&
|
||||
new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') &&
|
||||
ctx.req.headers.host === new URL(this.config.origin).host
|
||||
) {
|
||||
const cookiePrefix = this.config.node.prod ? '__Secure-' : '';
|
||||
const sessionCookieName = `${cookiePrefix}next-auth.session-token`;
|
||||
sessionToken = ctx.req.cookies?.[sessionCookieName];
|
||||
}
|
||||
const session = await this.auth.createUserSession(
|
||||
user,
|
||||
undefined,
|
||||
this.config.auth.accessToken.ttl
|
||||
);
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
token: this.auth.sign(user),
|
||||
refresh: this.auth.refresh(user),
|
||||
sessionToken: session.sessionId,
|
||||
token: session.sessionId,
|
||||
refresh: '',
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -98,16 +109,19 @@ export class AuthResolver {
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request },
|
||||
@Context() ctx: { req: Request; res: Response },
|
||||
@Args('name') name: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
validators.assertValidCredential({ email, password });
|
||||
const user = await this.auth.signUp(name, email, password);
|
||||
await this.auth.setCookie(ctx.req, ctx.res, user);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -116,11 +130,13 @@ export class AuthResolver {
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signIn(
|
||||
@Context() ctx: { req: Request },
|
||||
@Context() ctx: { req: Request; res: Response },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
validators.assertValidEmail(email);
|
||||
const user = await this.auth.signIn(email, password);
|
||||
await this.auth.setCookie(ctx.req, ctx.res, user);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
@@ -132,28 +148,26 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changePassword(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
const id = await this.session.get(token);
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify the email first');
|
||||
}
|
||||
if (
|
||||
!id ||
|
||||
(id !== user.id &&
|
||||
// change password after sign in with email link
|
||||
// we only create user account after user sign in with email link
|
||||
id !== user.email)
|
||||
) {
|
||||
validators.assertValidPassword(newPassword);
|
||||
// NOTE: Set & Change password are using the same token type.
|
||||
const valid = await this.token.verifyToken(
|
||||
TokenType.ChangePassword,
|
||||
token,
|
||||
{
|
||||
credential: user.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(user.email, newPassword);
|
||||
await this.session.delete(token);
|
||||
await this.auth.changePassword(user.id, newPassword);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -165,25 +179,24 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string
|
||||
) {
|
||||
const key = await this.session.get(token);
|
||||
if (!key) {
|
||||
validators.assertValidEmail(email);
|
||||
// @see [sendChangeEmail]
|
||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
// email has set token in `sendVerifyChangeEmail`
|
||||
const [id, email] = key.split(',');
|
||||
if (!id || id !== user.id || !email) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changeEmail(id, email);
|
||||
await this.session.delete(token);
|
||||
email = decodeURIComponent(email);
|
||||
|
||||
await this.auth.changeEmail(user.id, email);
|
||||
await this.auth.sendNotificationChangeEmail(email);
|
||||
|
||||
return user;
|
||||
@@ -196,19 +209,29 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangePasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
// @deprecated
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
|
||||
const res = await this.auth.sendChangePasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -219,19 +242,27 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendSetPasswordEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
|
||||
const res = await this.auth.sendSetPasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -249,19 +280,22 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
// @deprecated
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangeEmail(email, url.toString());
|
||||
const res = await this.auth.sendChangeEmail(user.email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -272,34 +306,92 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendVerifyChangeEmail(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
validators.assertValidEmail(email);
|
||||
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const hasRegistered = await this.auth.getUserByEmail(email);
|
||||
const hasRegistered = await this.user.findUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
throw new BadRequestException(`Invalid user email`);
|
||||
if (hasRegistered.id !== user.id) {
|
||||
throw new BadRequestException(`The email provided has been taken.`);
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
`The email provided is the same as the current email.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const withEmailToken = nanoid();
|
||||
await this.session.set(withEmailToken, `${user.id},${email}`);
|
||||
const verifyEmailToken = await this.token.createToken(
|
||||
TokenType.VerifyEmail,
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', withEmailToken);
|
||||
url.searchParams.set('token', verifyEmailToken);
|
||||
url.searchParams.set('email', email);
|
||||
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
|
||||
|
||||
await this.session.delete(token);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
async sendVerifyEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
async verifyEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string
|
||||
) {
|
||||
if (!token) {
|
||||
throw new BadRequestException('Invalid token');
|
||||
}
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
|
||||
|
||||
return emailVerifiedAt !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,299 +1,327 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
NotAcceptableException,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
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,
|
||||
MailService,
|
||||
verifyChallengeResponse,
|
||||
} from '../../fundamentals';
|
||||
import { Quota_FreePlanV1_1 } from '../quota';
|
||||
import { Config, CryptoHelper, MailService } from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { UserService } from '../user/service';
|
||||
import type { CurrentUser } from './current-user';
|
||||
|
||||
export type UserClaim = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
|
||||
> & {
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
export function parseAuthUserSeqNum(value: any) {
|
||||
let seq: number = 0;
|
||||
switch (typeof value) {
|
||||
case 'number': {
|
||||
seq = value;
|
||||
break;
|
||||
}
|
||||
case 'string': {
|
||||
const result = value.match(/^([\d{0, 10}])$/);
|
||||
if (result?.[1]) {
|
||||
seq = Number(result[1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
|
||||
default: {
|
||||
seq = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, seq);
|
||||
}
|
||||
|
||||
export function sessionUser(
|
||||
user: Pick<
|
||||
User,
|
||||
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
|
||||
> & { password?: string | null }
|
||||
): CurrentUser {
|
||||
return assign(
|
||||
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
|
||||
{
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
export class AuthService implements OnApplicationBootstrap {
|
||||
readonly cookieOptions: CookieOptions = {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
secure: this.config.https,
|
||||
};
|
||||
static readonly sessionCookieName = 'affine_session';
|
||||
static readonly authUserSeqHeaderName = 'x-auth-user';
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly mailer: MailService
|
||||
private readonly db: PrismaClient,
|
||||
private readonly mailer: MailService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly user: UserService,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + this.config.auth.accessTokenExpiresIn,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
refresh(user: UserClaim) {
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
exp: now + this.config.auth.refreshTokenExpiresIn,
|
||||
iat: now,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async verify(token: string) {
|
||||
try {
|
||||
const data = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
aud: ['https://affine.pro'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
|
||||
return {
|
||||
...data,
|
||||
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
|
||||
createdAt: new Date(data.createdAt),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
async onApplicationBootstrap() {
|
||||
if (this.config.node.dev) {
|
||||
await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCaptchaToken(token: any, ip: string) {
|
||||
if (typeof token !== 'string' || !token) return false;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('secret', this.config.auth.captcha.turnstile.secret);
|
||||
formData.append('response', token);
|
||||
formData.append('remoteip', ip);
|
||||
// prevent replay attack
|
||||
formData.append('idempotency_key', nanoid());
|
||||
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome = await result.json();
|
||||
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(this.config.node.dev || outcome.hostname === this.config.host)
|
||||
);
|
||||
canSignIn(email: string) {
|
||||
return this.feature.canEarlyAccess(email);
|
||||
}
|
||||
|
||||
async verifyChallengeResponse(response: any, resource: string) {
|
||||
return verifyChallengeResponse(
|
||||
response,
|
||||
this.config.auth.captcha.challenge.bits,
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('User has no password');
|
||||
}
|
||||
let equal = false;
|
||||
try {
|
||||
equal = await verify(user.password, password);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e, 'Verify password failed');
|
||||
}
|
||||
if (!equal) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
async signUp(
|
||||
name: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<CurrentUser> {
|
||||
const user = await this.user.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
throw new BadRequestException('Email was taken');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
const hashedPassword = await this.crypto.encryptPassword(password);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
return this.user
|
||||
.createUser({
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
// TODO(@forehalo): handle in event system
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by api sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.then(sessionUser);
|
||||
}
|
||||
|
||||
async createAnonymousUser(email: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
async signIn(email: string, password: string) {
|
||||
const user = await this.user.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name: 'Unnamed',
|
||||
email,
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by invite sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isUserHasPassword(email: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
}
|
||||
return Boolean(user.password);
|
||||
|
||||
if (!user.password) {
|
||||
throw new NotAcceptableException(
|
||||
'User Password is not set. Should login through email link.'
|
||||
);
|
||||
}
|
||||
|
||||
const passwordMatches = await this.crypto.verifyPassword(
|
||||
password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
// no such session
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userSession = session.userSessions.at(seq);
|
||||
|
||||
// no such user session
|
||||
if (!userSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// user session expired
|
||||
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
where: { id: userSession.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async getUserList(token: string) {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
if (!session || !session.userSessions.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const users = await this.db.user.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
emailVerified: {
|
||||
not: null,
|
||||
id: {
|
||||
in: session.userSessions.map(({ userId }) => userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(@forehalo): need to separate expired session, same for [getUser]
|
||||
// Session
|
||||
// | { user: LimitedUser { email, avatarUrl }, expired: true }
|
||||
// | { user: User, expired: false }
|
||||
return session.userSessions
|
||||
.map(userSession => {
|
||||
// keep users in the same order as userSessions
|
||||
const user = users.find(({ id }) => id === userSession.userId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return sessionUser(user);
|
||||
})
|
||||
.filter(Boolean) as CurrentUser[];
|
||||
}
|
||||
|
||||
async signOut(token: string, seq = 0) {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
if (session) {
|
||||
// overflow the logged in user
|
||||
if (session.userSessions.length <= seq) {
|
||||
return session;
|
||||
}
|
||||
|
||||
await this.db.userSession.deleteMany({
|
||||
where: { id: session.userSessions[seq].id },
|
||||
});
|
||||
|
||||
// no more user session active, delete the whole session
|
||||
if (session.userSessions.length === 1) {
|
||||
await this.db.session.delete({ where: { id: session.id } });
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSession(token: string) {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.db.$transaction(async tx => {
|
||||
const session = await tx.session.findUnique({
|
||||
where: {
|
||||
id: token,
|
||||
},
|
||||
include: {
|
||||
userSessions: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.expiresAt && session.expiresAt <= new Date()) {
|
||||
await tx.session.delete({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
async createUserSession(
|
||||
user: { id: string },
|
||||
existingSession?: string,
|
||||
ttl = this.config.auth.session.ttl
|
||||
) {
|
||||
const session = existingSession
|
||||
? await this.getSession(existingSession)
|
||||
: null;
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
if (session) {
|
||||
return this.db.userSession.upsert({
|
||||
where: {
|
||||
sessionId_userId: {
|
||||
sessionId: session.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
expiresAt,
|
||||
},
|
||||
create: {
|
||||
sessionId: session.id,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return this.db.userSession.create({
|
||||
data: {
|
||||
expiresAt,
|
||||
session: {
|
||||
create: {},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setCookie(_req: Request, res: Response, user: { id: string }) {
|
||||
const session = await this.createUserSession(
|
||||
user
|
||||
// TODO(@forehalo): enable multi user session
|
||||
// req.cookies[AuthService.sessionCookieName]
|
||||
);
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, session.sessionId, {
|
||||
expires: session.expiresAt ?? void 0,
|
||||
...this.cookieOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword);
|
||||
const hashedPassword = await this.crypto.encryptPassword(newPassword);
|
||||
|
||||
return this.prisma.user.update({
|
||||
return this.db.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
@@ -304,22 +332,33 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
return this.db.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
email: newEmail,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setEmailVerified(id: string) {
|
||||
return await this.db.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
emailVerifiedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -336,7 +375,20 @@ export class AuthService {
|
||||
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
|
||||
}
|
||||
async sendVerifyEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyEmail(email, callbackUrl);
|
||||
}
|
||||
async sendNotificationChangeEmail(email: string) {
|
||||
return this.mailer.sendNotificationChangeEmail(email);
|
||||
}
|
||||
|
||||
async sendSignInEmail(email: string, link: string, signUp: boolean) {
|
||||
return signUp
|
||||
? await this.mailer.sendSignUpMail(link.toString(), {
|
||||
to: email,
|
||||
})
|
||||
: await this.mailer.sendSignInMail(link.toString(), {
|
||||
to: email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
96
packages/backend/server/src/core/auth/token.ts
Normal file
96
packages/backend/server/src/core/auth/token.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
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';
|
||||
|
||||
export enum TokenType {
|
||||
SignIn,
|
||||
VerifyEmail,
|
||||
ChangeEmail,
|
||||
ChangePassword,
|
||||
Challenge,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
async createToken(
|
||||
type: TokenType,
|
||||
credential?: string,
|
||||
ttlInSec: number = 30 * 60
|
||||
) {
|
||||
const plaintextToken = randomUUID();
|
||||
|
||||
const { token } = await this.db.verificationToken.create({
|
||||
data: {
|
||||
type,
|
||||
token: plaintextToken,
|
||||
credential,
|
||||
expiresAt: new Date(Date.now() + ttlInSec * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return this.crypto.encrypt(token);
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
type: TokenType,
|
||||
token: string,
|
||||
{
|
||||
credential,
|
||||
keep,
|
||||
}: {
|
||||
credential?: string;
|
||||
keep?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
token = this.crypto.decrypt(token);
|
||||
const record = await this.db.verificationToken.findUnique({
|
||||
where: {
|
||||
type_token: {
|
||||
token,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expired = record.expiresAt <= new Date();
|
||||
const valid =
|
||||
!expired && (!record.credential || record.credential === credential);
|
||||
|
||||
if ((expired || valid) && !keep) {
|
||||
await this.db.verificationToken.delete({
|
||||
where: {
|
||||
type_token: {
|
||||
token,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return valid ? record : null;
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
cleanExpiredTokens() {
|
||||
return this.db.verificationToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
|
||||
export { sendVerificationRequest } from './send-mail';
|
||||
export type { SendVerificationRequestParams } from 'next-auth/providers/email';
|
||||
@@ -1,76 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
|
||||
import { Config } from '../../../fundamentals';
|
||||
import { getUtcTimestamp, UserClaim } from '../service';
|
||||
|
||||
export const jwtEncode = async (
|
||||
config: Config,
|
||||
prisma: PrismaClient,
|
||||
token: JWT | undefined,
|
||||
maxAge: number | undefined
|
||||
) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
picture: user.avatarUrl,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
hasPassword: Boolean(user.password),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const jwtDecode = async (config: Config, token: string | undefined) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, emailVerified, id, picture, hasPassword } = (
|
||||
await jwtVerify(token, config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [config.serverId],
|
||||
leeway: config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as Omit<UserClaim, 'avatarUrl'> & {
|
||||
picture: string | undefined;
|
||||
};
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
picture,
|
||||
sub: id,
|
||||
id,
|
||||
hasPassword,
|
||||
};
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
|
||||
|
||||
import { Config, MailService, SessionService } from '../../../fundamentals';
|
||||
|
||||
export async function sendVerificationRequest(
|
||||
config: Config,
|
||||
logger: Logger,
|
||||
mailer: MailService,
|
||||
session: SessionService,
|
||||
params: SendVerificationRequestParams
|
||||
) {
|
||||
const { identifier, url } = params;
|
||||
const urlWithToken = new URL(url);
|
||||
const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
} else {
|
||||
const newCallbackUrl = new URL(callbackUrl, config.origin);
|
||||
|
||||
const token = nanoid();
|
||||
await session.set(token, identifier);
|
||||
newCallbackUrl.searchParams.set('token', token);
|
||||
|
||||
urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString());
|
||||
}
|
||||
|
||||
const result = await mailer.sendSignInEmail(urlWithToken.toString(), {
|
||||
to: identifier,
|
||||
});
|
||||
logger.log(`send verification email success: ${result.accepted.join(', ')}`);
|
||||
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../fundamentals';
|
||||
import { Public } from './auth';
|
||||
|
||||
export enum ServerFeature {
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
@@ -15,9 +17,23 @@ registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
const ENABLED_FEATURES: ServerFeature[] = [];
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.push(feature);
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordLimitsType {
|
||||
@Field()
|
||||
minLength!: number;
|
||||
@Field()
|
||||
maxLength!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CredentialsRequirementType {
|
||||
@Field()
|
||||
password!: PasswordLimitsType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -45,9 +61,15 @@ export class ServerConfigType {
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => CredentialsRequirementType, {
|
||||
description: 'credentials requirement',
|
||||
})
|
||||
credentialsRequirement!: CredentialsRequirementType;
|
||||
}
|
||||
|
||||
export class ServerConfigResolver {
|
||||
@Public()
|
||||
@Query(() => ServerConfigType, {
|
||||
description: 'server config',
|
||||
})
|
||||
@@ -61,7 +83,10 @@ export class ServerConfigResolver {
|
||||
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||
// this field should be removed after frontend feature flags implemented
|
||||
flavor: AFFiNE.type,
|
||||
features: ENABLED_FEATURES,
|
||||
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.
|
||||
@@ -229,12 +239,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
update: Buffer,
|
||||
retryTimes = 10
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timestamp = await new Promise<number>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid);
|
||||
await this.db.update.create({
|
||||
const { createdAt } = await this.db.update.create({
|
||||
select: {
|
||||
seq: true,
|
||||
createdAt: true,
|
||||
},
|
||||
data: {
|
||||
workspaceId,
|
||||
@@ -243,23 +253,27 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
blob: update,
|
||||
},
|
||||
});
|
||||
|
||||
return createdAt.getTime();
|
||||
})
|
||||
.pipe(retry(retryTimes)) // retry until seq num not conflict
|
||||
.subscribe({
|
||||
next: () => {
|
||||
next: timestamp => {
|
||||
this.logger.debug(
|
||||
`pushed 1 update for ${guid} in workspace ${workspaceId}`
|
||||
);
|
||||
resolve();
|
||||
resolve(timestamp);
|
||||
},
|
||||
error: e => {
|
||||
this.logger.error('Failed to push updates', e);
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
}).then(() => {
|
||||
return this.updateCachedUpdatesCount(workspaceId, guid, 1);
|
||||
});
|
||||
|
||||
await this.updateCachedUpdatesCount(workspaceId, guid, 1);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
async batchPush(
|
||||
@@ -268,56 +282,124 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
updates: Buffer[],
|
||||
retryTimes = 10
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timestamp = await new Promise<number>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid, updates.length);
|
||||
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)) {
|
||||
await this.db.update.createMany({
|
||||
data: batch.map((update, i) => ({
|
||||
workspaceId,
|
||||
id: guid,
|
||||
data: batch.map((update, i) => {
|
||||
const subSeq = turn * batchCount + i + 1;
|
||||
// `seq` is the last seq num of the batch
|
||||
// example for 11 batched updates, start from seq num 20
|
||||
// seq for first update in the batch should be:
|
||||
// 31 - 11 + 0 * 10 + 0 + 1 = 21
|
||||
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
|
||||
seq: seq - updates.length + turn * batchCount + i + 1,
|
||||
blob: update,
|
||||
})),
|
||||
// 31 - 11 + subSeq(0 * 10 + 0 + 1) = 21
|
||||
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
|
||||
const seq = lastSeq - updates.length + subSeq;
|
||||
const createdAt = now + subSeq;
|
||||
timestamp = Math.max(timestamp, createdAt);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
blob: update,
|
||||
seq,
|
||||
createdAt: new Date(createdAt), // make sure the updates can be ordered by create time
|
||||
};
|
||||
}),
|
||||
});
|
||||
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);
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
}).then(() => {
|
||||
return this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
|
||||
});
|
||||
await this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest timestamp of all docs in the workspace.
|
||||
*/
|
||||
@CallTimer('doc', 'get_doc_timestamps')
|
||||
async getDocTimestamps(workspaceId: string, after: number | undefined = 0) {
|
||||
const snapshots = await this.db.snapshot.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
updatedAt: {
|
||||
gt: new Date(after),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updates = await this.db.update.groupBy({
|
||||
where: {
|
||||
workspaceId,
|
||||
createdAt: {
|
||||
gt: new Date(after),
|
||||
},
|
||||
},
|
||||
by: ['id'],
|
||||
_max: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
snapshots.forEach(s => {
|
||||
result[s.id] = s.updatedAt.getTime();
|
||||
});
|
||||
|
||||
updates.forEach(u => {
|
||||
if (u._max.createdAt) {
|
||||
result[u.id] = u._max.createdAt.getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -538,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');
|
||||
}
|
||||
@@ -597,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,5 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PrismaTransaction } from '../../fundamentals';
|
||||
import { Feature, FeatureSchema, FeatureType } from './types';
|
||||
|
||||
class FeatureConfig {
|
||||
@@ -67,7 +66,7 @@ export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
||||
|
||||
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
|
||||
|
||||
export async function getFeature(prisma: PrismaClient, featureId: number) {
|
||||
export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
const cachedQuota = FeatureCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
|
||||
@@ -115,4 +115,10 @@ export class FeatureManagementService {
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
|
||||
async getUserFeatures(userId: string): Promise<FeatureType[]> {
|
||||
return (await this.feature.getUserFeatures(userId)).map(
|
||||
f => f.feature.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { UserType } from '../users/types';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
@@ -158,7 +157,7 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
@@ -175,7 +174,7 @@ export class FeatureService {
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
emailVerifiedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PrismaTransaction } from '../../fundamentals';
|
||||
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
|
||||
|
||||
const QuotaCache = new Map<number, QuotaConfig>();
|
||||
@@ -7,14 +6,14 @@ const QuotaCache = new Map<number, QuotaConfig>();
|
||||
export class QuotaConfig {
|
||||
readonly config: Quota;
|
||||
|
||||
static async get(prisma: PrismaClient, featureId: number) {
|
||||
static async get(tx: PrismaTransaction, featureId: number) {
|
||||
const cachedQuota = QuotaCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const quota = await prisma.features.findFirst({
|
||||
const quota = await tx.features.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeatureKind } from '../features';
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { OneDay, OneGB, OneMB } from './constant';
|
||||
import { Quota, QuotaType } from './types';
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { type EventPayload, OnEvent } from '../../fundamentals';
|
||||
import type { EventPayload } from '../../fundamentals';
|
||||
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
||||
import { FeatureKind } from '../features';
|
||||
import { QuotaConfig } from './quota';
|
||||
import { QuotaType } from './types';
|
||||
|
||||
type Transaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
@@ -140,8 +139,8 @@ export class QuotaService {
|
||||
});
|
||||
}
|
||||
|
||||
async hasQuota(userId: string, quota: QuotaType, transaction?: Transaction) {
|
||||
const executor = transaction ?? this.prisma;
|
||||
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||
const executor = tx ?? this.prisma;
|
||||
|
||||
return executor.userFeatures
|
||||
.count({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commonFeatureSchema, FeatureKind } from '../features';
|
||||
import { commonFeatureSchema, FeatureKind } from '../features/types';
|
||||
import { ByteUnit, OneDay, OneKB } from './constant';
|
||||
|
||||
/// ======== quota define ========
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
import { CallTimer, metrics } from '../../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocManager } from '../../doc';
|
||||
import { UserType } from '../../users';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService } from '../../workspaces/permission';
|
||||
import { Permission } from '../../workspaces/types';
|
||||
@@ -39,26 +38,21 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
let result: any;
|
||||
desc.value = async function (...args: any[]) {
|
||||
try {
|
||||
result = originalMethod.apply(this, args);
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (e) {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch(e => {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
if (e instanceof EventError) {
|
||||
return {
|
||||
error: new InternalError(e),
|
||||
error: e,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return result;
|
||||
} else {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
new Logger('EventsGateway').error(e, (e as Error).stack);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,8 +79,16 @@ type EventResponse<Data = any> =
|
||||
data: Data;
|
||||
});
|
||||
|
||||
function Sync(workspaceId: string): `${string}:sync` {
|
||||
return `${workspaceId}:sync`;
|
||||
}
|
||||
|
||||
function Awareness(workspaceId: string): `${string}:awareness` {
|
||||
return `${workspaceId}:awareness`;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: process.env.NODE_ENV !== 'production',
|
||||
cors: !AFFiNE.node.prod,
|
||||
transports: ['websocket'],
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
@@ -113,7 +115,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
checkVersion(client: Socket, version?: string) {
|
||||
assertVersion(client: Socket, version?: string) {
|
||||
if (
|
||||
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
||||
AFFiNE.featureFlags.syncClientVersionCheck &&
|
||||
@@ -126,98 +128,93 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
version ? ` ${version}` : ''
|
||||
} is outdated, please update to ${AFFiNE.version}`,
|
||||
});
|
||||
return {
|
||||
error: new EventError(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||
),
|
||||
};
|
||||
|
||||
throw new EventError(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async joinWorkspace(
|
||||
client: Socket,
|
||||
room: `${string}:${'sync' | 'awareness'}`
|
||||
) {
|
||||
await client.join(room);
|
||||
}
|
||||
|
||||
async leaveWorkspace(
|
||||
client: Socket,
|
||||
room: `${string}:${'sync' | 'awareness'}`
|
||||
) {
|
||||
await client.leave(room);
|
||||
}
|
||||
|
||||
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
||||
if (!client.rooms.has(room)) {
|
||||
throw new NotInWorkspaceError(room);
|
||||
}
|
||||
}
|
||||
|
||||
async assertWorkspaceAccessible(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
if (
|
||||
!(await this.permissions.isWorkspaceMember(
|
||||
workspaceId,
|
||||
userId,
|
||||
permission
|
||||
))
|
||||
) {
|
||||
throw new AccessDeniedError(workspaceId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake-sync')
|
||||
async handleClientHandshakeSync(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const versionError = this.checkVersion(client, version);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
if (canWrite) {
|
||||
await client.join(`${workspaceId}:sync`);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
await this.joinWorkspace(client, Sync(workspaceId));
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake-awareness')
|
||||
async handleClientHandshakeAwareness(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const versionError = this.checkVersion(client, version);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
if (canWrite) {
|
||||
await client.join(`${workspaceId}:awareness`);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `client-handshake-sync` and `client-handshake-awareness` instead
|
||||
*/
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake')
|
||||
async handleClientHandShake(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const versionError = this.checkVersion(client);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
// should unreachable
|
||||
await this.joinWorkspace(client, Awareness(workspaceId));
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,14 +223,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(`${workspaceId}:sync`)) {
|
||||
await client.leave(`${workspaceId}:sync`);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
await this.leaveWorkspace(client, Sync(workspaceId));
|
||||
return {};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave-awareness')
|
||||
@@ -241,14 +233,27 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
||||
await client.leave(`${workspaceId}:awareness`);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
await this.leaveWorkspace(client, Awareness(workspaceId));
|
||||
return {};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-pre-sync')
|
||||
async loadDocStats(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody()
|
||||
{ workspaceId, timestamp }: { workspaceId: string; timestamp?: number }
|
||||
): Promise<EventResponse<Record<string, number>>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
|
||||
const stats = await this.docManager.getDocTimestamps(
|
||||
workspaceId,
|
||||
timestamp
|
||||
);
|
||||
|
||||
return {
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-update-v2')
|
||||
@@ -264,33 +269,32 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
updates: string[];
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ accepted: true }>> {
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
client
|
||||
.to(`${docId.workspace}:sync`)
|
||||
.emit('server-updates', { workspaceId, guid, updates });
|
||||
|
||||
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
||||
const timestamp = await this.docManager.batchPush(
|
||||
docId.workspace,
|
||||
docId.guid,
|
||||
buffers
|
||||
);
|
||||
|
||||
client
|
||||
.to(Sync(workspaceId))
|
||||
.emit('server-updates', { workspaceId, guid, updates, timestamp });
|
||||
|
||||
await this.docManager.batchPush(docId.workspace, docId.guid, buffers);
|
||||
return {
|
||||
data: {
|
||||
accepted: true,
|
||||
timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('doc-load-v2')
|
||||
async loadDocV2(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
@@ -301,23 +305,15 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
guid: string;
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
const canRead = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id
|
||||
);
|
||||
if (!canRead) {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
): 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),
|
||||
};
|
||||
@@ -325,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -344,34 +341,28 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
||||
client.to(`${workspaceId}:awareness`).emit('new-client-awareness-init');
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
client.to(Awareness(workspaceId)).emit('new-client-awareness-init');
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-update')
|
||||
async handleHelpGatheringAwareness(
|
||||
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
awarenessUpdate,
|
||||
}: { workspaceId: string; awarenessUpdate: string },
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(`${message.workspaceId}:awareness`)) {
|
||||
client
|
||||
.to(`${message.workspaceId}:awareness`)
|
||||
.emit('server-awareness-broadcast', message);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(message.workspaceId),
|
||||
};
|
||||
}
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
client
|
||||
.to(Awareness(workspaceId))
|
||||
.emit('server-awareness-broadcast', { workspaceId, awarenessUpdate });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserManagementResolver } from './management';
|
||||
import { UserResolver } from './resolver';
|
||||
import { UsersService } from './users';
|
||||
import { UserService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||
providers: [UserResolver, UserManagementResolver, UsersService],
|
||||
providers: [UserResolver, UserManagementResolver, UserService],
|
||||
controllers: [UserAvatarController],
|
||||
exports: [UsersService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
export class UserModule {}
|
||||
|
||||
export { UserService } from './service';
|
||||
export { UserType } from './types';
|
||||
export { UsersService } from './users';
|
||||
@@ -6,23 +6,21 @@ import {
|
||||
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../auth/guard';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { UserService } from './service';
|
||||
import { UserType } from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@@ -34,7 +32,7 @@ export class UserManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
@@ -44,7 +42,9 @@ export class UserManagementResolver {
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
} else {
|
||||
const user = await this.auth.createAnonymousUser(email);
|
||||
const user = await this.users.createAnonymousUser(email, {
|
||||
registered: false,
|
||||
});
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class UserManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
@@ -79,13 +79,15 @@ export class UserManagementResolver {
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() user: UserType
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<UserType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
// allow query other user's subscription
|
||||
ctx.isAdminQuery = true;
|
||||
return this.feature.listEarlyAccess();
|
||||
return this.feature.listEarlyAccess().then(users => {
|
||||
return users.map(sessionUser);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,78 +7,50 @@ 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';
|
||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { QuotaService } from '../quota';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
UserQuotaType,
|
||||
UserType,
|
||||
} from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly users: UsersService,
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly event: EventEmitter
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Publicable()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
async currentUser(@CurrentUser() user?: UserType) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedUser = await this.users.findUserById(user.id);
|
||||
if (!storedUser) {
|
||||
throw new BadRequestException(`User ${user.id} not found in db`);
|
||||
}
|
||||
return {
|
||||
id: storedUser.id,
|
||||
name: storedUser.name,
|
||||
email: storedUser.email,
|
||||
emailVerified: storedUser.emailVerified,
|
||||
avatarUrl: storedUser.avatarUrl,
|
||||
createdAt: storedUser.createdAt,
|
||||
hasPassword: !!storedUser.password,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -92,9 +64,9 @@ export class UserResolver {
|
||||
})
|
||||
@Public()
|
||||
async user(
|
||||
@CurrentUser() currentUser?: UserType,
|
||||
@CurrentUser() currentUser?: CurrentUser,
|
||||
@Args('email') email?: string
|
||||
) {
|
||||
): Promise<typeof UserOrLimitedUser | null> {
|
||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
@@ -102,16 +74,19 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (currentUser) return user;
|
||||
const user = await this.users.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
// return empty response when user not exists
|
||||
if (!user) return null;
|
||||
|
||||
if (currentUser) {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
// only return limited info when not logged in
|
||||
return {
|
||||
email: user?.email,
|
||||
hasPassword: !!user?.password,
|
||||
email: user.email,
|
||||
hasPassword: !!user.password,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,12 +103,21 @@ export class UserResolver {
|
||||
name: 'invoiceCount',
|
||||
description: 'Get user invoice count',
|
||||
})
|
||||
async invoiceCount(@CurrentUser() user: UserType) {
|
||||
async invoiceCount(@CurrentUser() user: CurrentUser) {
|
||||
return this.prisma.userInvoice.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 10, ttl: 60 } })
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
description: 'Enabled features of a user',
|
||||
})
|
||||
async userFeatures(@CurrentUser() user: CurrentUser) {
|
||||
return this.feature.getUserFeatures(user.id);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -145,7 +129,7 @@ export class UserResolver {
|
||||
description: 'Upload user avatar',
|
||||
})
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
@@ -169,6 +153,33 @@ export class UserResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType, {
|
||||
name: 'updateProfile',
|
||||
})
|
||||
async updateUserProfile(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput
|
||||
): Promise<UserType> {
|
||||
input = omitBy(input, isNil);
|
||||
|
||||
if (Object.keys(input).length === 0) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: input,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -179,7 +190,7 @@ export class UserResolver {
|
||||
name: 'removeAvatar',
|
||||
description: 'Remove user avatar',
|
||||
})
|
||||
async removeAvatar(@CurrentUser() user: UserType) {
|
||||
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
@@ -197,7 +208,9 @@ export class UserResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => DeleteAccount)
|
||||
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<DeleteAccount> {
|
||||
const deletedUser = await this.users.deleteUser(user.id);
|
||||
this.event.emit('user.deleted', deletedUser);
|
||||
return { success: true };
|
||||
133
packages/backend/server/src/core/user/service.ts
Normal file
133
packages/backend/server/src/core/user/service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Quota_FreePlanV1_1 } from '../quota/schema';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
defaultUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
avatarUrl: true,
|
||||
registered: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
get userCreatingData() {
|
||||
return {
|
||||
name: 'Unnamed',
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by invite sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(data: Prisma.UserCreateInput) {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAnonymousUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.createUser({
|
||||
email,
|
||||
name: email.split('@')[0],
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
select: this.defaultUserSelect,
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: this.defaultUserSelect,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* supposed to be used only for `Credential SignIn`
|
||||
*/
|
||||
async findUserWithHashedPasswordByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
return this.createAnonymousUser(email, data);
|
||||
}
|
||||
|
||||
async fulfillUser(
|
||||
email: string,
|
||||
data: Partial<
|
||||
Pick<Prisma.UserCreateInput, 'emailVerifiedAt' | 'registered'>
|
||||
>
|
||||
) {
|
||||
return this.prisma.user.upsert({
|
||||
select: this.defaultUserSelect,
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
update: data,
|
||||
create: {
|
||||
email,
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import {
|
||||
createUnionType,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
ObjectType,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
|
||||
@ObjectType('UserQuotaHumanReadable')
|
||||
export class UserQuotaHumanReadableType {
|
||||
@Field({ name: 'name' })
|
||||
@@ -42,7 +50,7 @@ export class UserQuotaType {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements Partial<User> {
|
||||
export class UserType implements CurrentUser {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@@ -53,19 +61,25 @@ export class UserType implements Partial<User> {
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { description: 'User avatar url', nullable: true })
|
||||
avatarUrl: string | null = null;
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Field(() => Date, { description: 'User email verified', nullable: true })
|
||||
emailVerified: Date | null = null;
|
||||
|
||||
@Field({ description: 'User created date', nullable: true })
|
||||
createdAt!: Date;
|
||||
@Field(() => Boolean, {
|
||||
description: 'User email verified',
|
||||
})
|
||||
emailVerified!: boolean;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword?: boolean;
|
||||
hasPassword!: boolean | null;
|
||||
|
||||
@Field(() => Date, {
|
||||
deprecationReason: 'useless',
|
||||
description: 'User email verified',
|
||||
nullable: true,
|
||||
})
|
||||
createdAt?: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -77,7 +91,7 @@ export class LimitedUserType implements Partial<User> {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword?: boolean;
|
||||
hasPassword!: boolean | null;
|
||||
}
|
||||
|
||||
export const UserOrLimitedUser = createUnionType({
|
||||
@@ -101,3 +115,9 @@ export class RemoveAvatar {
|
||||
@Field()
|
||||
success!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateUserInput implements Partial<User> {
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
56
packages/backend/server/src/core/utils/validators.ts
Normal file
56
packages/backend/server/src/core/utils/validators.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import z from 'zod';
|
||||
|
||||
function getAuthCredentialValidator() {
|
||||
const email = z.string().email({ message: 'Invalid email address' });
|
||||
let password = z.string();
|
||||
|
||||
password = password
|
||||
.min(AFFiNE.auth.password.minLength, {
|
||||
message: `Password must be ${AFFiNE.auth.password.minLength} or more charactors long`,
|
||||
})
|
||||
.max(AFFiNE.auth.password.maxLength, {
|
||||
message: `Password must be ${AFFiNE.auth.password.maxLength} or fewer charactors long`,
|
||||
});
|
||||
|
||||
return z
|
||||
.object({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
.required();
|
||||
}
|
||||
|
||||
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
|
||||
const result = z.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues.at(0);
|
||||
if (firstIssue) {
|
||||
throw new BadRequestException(firstIssue.message);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid credential');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidEmail(email: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.email, email);
|
||||
}
|
||||
|
||||
export function assertValidPassword(password: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.password, password);
|
||||
}
|
||||
|
||||
export function assertValidCredential(credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
assertValid(getAuthCredentialValidator(), credential);
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
assertValidEmail,
|
||||
assertValidPassword,
|
||||
assertValidCredential,
|
||||
};
|
||||
@@ -11,10 +11,9 @@ import { PrismaClient } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { CallTimer } from '../../fundamentals';
|
||||
import { Auth, CurrentUser, Publicable } from '../auth';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { DocHistoryManager, DocManager } from '../doc';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { UserType } from '../users';
|
||||
import { DocID } from '../utils/doc';
|
||||
import { PermissionService, PublicPageMode } from './permission';
|
||||
import { Permission } from './types';
|
||||
@@ -33,6 +32,7 @@ export class WorkspacesController {
|
||||
// get workspace blob
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@Public()
|
||||
@Get('/:id/blobs/:name')
|
||||
@CallTimer('controllers', 'workspace_get_blob')
|
||||
async blob(
|
||||
@@ -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`);
|
||||
@@ -62,12 +62,11 @@ export class WorkspacesController {
|
||||
}
|
||||
|
||||
// get doc binary
|
||||
@Public()
|
||||
@Get('/:id/docs/:guid')
|
||||
@Auth()
|
||||
@Publicable()
|
||||
@CallTimer('controllers', 'workspace_get_doc')
|
||||
async doc(
|
||||
@CurrentUser() user: UserType | undefined,
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Res() res: Response
|
||||
@@ -84,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');
|
||||
}
|
||||
|
||||
@@ -107,15 +109,18 @@ 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')
|
||||
@Auth()
|
||||
@CallTimer('controllers', 'workspace_get_history')
|
||||
async history(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Param('timestamp') timestamp: string,
|
||||
@@ -144,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');
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DocModule } from '../doc';
|
||||
import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UsersService } from '../users';
|
||||
import { UserModule } from '../user';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { WorkspaceManagementResolver } from './management';
|
||||
import { PermissionService } from './permission';
|
||||
@@ -16,13 +16,12 @@ import {
|
||||
} from './resolvers';
|
||||
|
||||
@Module({
|
||||
imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
|
||||
imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
WorkspaceManagementResolver,
|
||||
PermissionService,
|
||||
UsersService,
|
||||
PagePermissionResolver,
|
||||
DocHistoryResolver,
|
||||
WorkspaceBlobResolver,
|
||||
|
||||
@@ -10,14 +10,12 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceType } from './types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceManagementResolver {
|
||||
constructor(
|
||||
@@ -33,7 +31,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addWorkspaceFeature(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<number> {
|
||||
@@ -52,7 +50,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeWorkspaceFeature(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<boolean> {
|
||||
@@ -71,7 +69,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Query(() => [WorkspaceType])
|
||||
async listWorkspaceFeatures(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<WorkspaceType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
@@ -83,7 +81,7 @@ export class WorkspaceManagementResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async setWorkspaceExperimentalFeature(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
|
||||
@Args('enable') enable: boolean
|
||||
@@ -117,7 +115,7 @@ export class WorkspaceManagementResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async availableFeatures(
|
||||
@CurrentUser() user: UserType
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<FeatureType[]> {
|
||||
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
|
||||
if (isEarlyAccessUser) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -73,6 +74,28 @@ export class PermissionService {
|
||||
return this.tryCheckPage(ws, id, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
||||
*/
|
||||
async isWorkspaceMember(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission
|
||||
): Promise<boolean> {
|
||||
const count = await this.prisma.workspaceUserPermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count !== 0;
|
||||
}
|
||||
|
||||
async checkWorkspace(
|
||||
ws: string,
|
||||
user?: string,
|
||||
|
||||
@@ -16,22 +16,20 @@ 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';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { FeatureManagementService, FeatureType } from '../../features';
|
||||
import { QuotaManagementService } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UserType } from '../../users';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceBlobResolver {
|
||||
logger = new Logger(WorkspaceBlobResolver.name);
|
||||
@@ -47,7 +45,7 @@ export class WorkspaceBlobResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async blobs(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspace.id, user.id);
|
||||
@@ -74,7 +72,7 @@ export class WorkspaceBlobResolver {
|
||||
})
|
||||
@MakeCache(['blobs'], ['workspaceId'])
|
||||
async listBlobs(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||
@@ -90,7 +88,7 @@ export class WorkspaceBlobResolver {
|
||||
@Query(() => WorkspaceBlobSizes, {
|
||||
deprecationReason: 'use `user.storageUsage` instead',
|
||||
})
|
||||
async collectAllBlobSizes(@CurrentUser() user: UserType) {
|
||||
async collectAllBlobSizes(@CurrentUser() user: CurrentUser) {
|
||||
const size = await this.quota.getUserUsage(user.id);
|
||||
return { size };
|
||||
}
|
||||
@@ -102,7 +100,7 @@ export class WorkspaceBlobResolver {
|
||||
deprecationReason: 'no more needed',
|
||||
})
|
||||
async checkBlobSize(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('size', { type: () => SafeIntResolver }) blobSize: number
|
||||
) {
|
||||
@@ -121,7 +119,7 @@ export class WorkspaceBlobResolver {
|
||||
@Mutation(() => String)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async setBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||
blob: FileUpload
|
||||
@@ -199,7 +197,7 @@ export class WorkspaceBlobResolver {
|
||||
@Mutation(() => Boolean)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async deleteBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('hash') name: string
|
||||
) {
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
import type { SnapshotHistory } from '@prisma/client';
|
||||
|
||||
import { CloudThrottlerGuard } from '../../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { DocHistoryManager } from '../../doc';
|
||||
import { UserType } from '../../users';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceType } from '../types';
|
||||
@@ -68,10 +67,9 @@ export class DocHistoryResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Mutation(() => Date)
|
||||
async recoverDoc(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('guid') guid: string,
|
||||
@Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date
|
||||
|
||||
@@ -9,14 +9,11 @@ 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 { Auth, CurrentUser } from '../../auth';
|
||||
import { UserType } from '../../users';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService, PublicPageMode } from '../permission';
|
||||
import { Permission, WorkspaceType } from '../types';
|
||||
@@ -42,7 +39,6 @@ class WorkspacePage implements Partial<PrismaWorkspacePage> {
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class PagePermissionResolver {
|
||||
constructor(
|
||||
@@ -90,7 +86,7 @@ export class PagePermissionResolver {
|
||||
deprecationReason: 'renamed to publicPage',
|
||||
})
|
||||
async deprecatedSharePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
@@ -100,7 +96,7 @@ export class PagePermissionResolver {
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async publishPage(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string,
|
||||
@Args({
|
||||
@@ -134,7 +130,7 @@ export class PagePermissionResolver {
|
||||
deprecationReason: 'use revokePublicPage',
|
||||
})
|
||||
async deprecatedRevokePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
@@ -144,7 +140,7 @@ export class PagePermissionResolver {
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async revokePublicPage(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
|
||||
@@ -15,23 +15,24 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
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,
|
||||
TooManyRequestsException,
|
||||
} from '../../../fundamentals';
|
||||
import { Auth, CurrentUser, Public } from '../../auth';
|
||||
import { AuthService } from '../../auth/service';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UsersService, UserType } from '../../users';
|
||||
import { UserService, UserType } from '../../user';
|
||||
import { PermissionService } from '../permission';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -48,20 +49,19 @@ import { defaultWorkspaceAvatar } from '../utils';
|
||||
* Other rate limit: 120 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly users: UsersService,
|
||||
private readonly users: UserService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly blobStorage: WorkspaceBlobStorage
|
||||
private readonly blobStorage: WorkspaceBlobStorage,
|
||||
private readonly mutex: MutexService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Permission, {
|
||||
@@ -69,7 +69,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async permission(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
@@ -160,7 +160,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async isOwner(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
|
||||
@@ -172,7 +172,7 @@ export class WorkspaceResolver {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces(@CurrentUser() user: User) {
|
||||
async workspaces(@CurrentUser() user: CurrentUser) {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -216,7 +216,7 @@ export class WorkspaceResolver {
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
|
||||
await this.permissions.checkWorkspace(id, user.id);
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
@@ -231,7 +231,7 @@ export class WorkspaceResolver {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
// we no longer support init workspace with a preload file
|
||||
// use sync system to uploading them once created
|
||||
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
|
||||
@@ -289,7 +289,7 @@ export class WorkspaceResolver {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
@@ -304,7 +304,10 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
async deleteWorkspace(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
@@ -320,7 +323,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => String)
|
||||
async invite(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('email') email: string,
|
||||
@Args('permission', { type: () => Permission }) permission: Permission,
|
||||
@@ -336,72 +339,87 @@ export class WorkspaceResolver {
|
||||
throw new ForbiddenException('Cannot change owner');
|
||||
}
|
||||
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
throw new PayloadTooLargeException('Workspace member limit reached.');
|
||||
}
|
||||
try {
|
||||
// lock to prevent concurrent invite
|
||||
const lockFlag = `invite:${workspaceId}`;
|
||||
await using lock = await this.mutex.lock(lockFlag);
|
||||
if (!lock) {
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
}
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord = await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.auth.createAnonymousUser(email);
|
||||
}
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
return new PayloadTooLargeException('Workspace member limit reached.');
|
||||
}
|
||||
|
||||
const inviteId = await this.permissions.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
try {
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord =
|
||||
await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.users.createAnonymousUser(email, {
|
||||
registered: false,
|
||||
});
|
||||
} catch (e) {
|
||||
const ret = await this.permissions.revokeWorkspace(
|
||||
workspaceId,
|
||||
target.id
|
||||
);
|
||||
}
|
||||
|
||||
if (!ret) {
|
||||
this.logger.fatal(
|
||||
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
|
||||
const inviteId = await this.permissions.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
try {
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const ret = await this.permissions.revokeWorkspace(
|
||||
workspaceId,
|
||||
target.id
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||
|
||||
if (!ret) {
|
||||
this.logger.fatal(
|
||||
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||
);
|
||||
}
|
||||
return new InternalServerErrorException(
|
||||
'Failed to send invite email. Please try again.'
|
||||
);
|
||||
}
|
||||
return new InternalServerErrorException(
|
||||
'Failed to send invite email. Please try again.'
|
||||
);
|
||||
}
|
||||
return inviteId;
|
||||
} catch (e) {
|
||||
this.logger.error('failed to invite user', e);
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
}
|
||||
return inviteId;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
@@ -470,7 +488,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revoke(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
@@ -514,7 +532,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async leaveWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('workspaceName') workspaceName: string,
|
||||
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { Workspace } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { UserType } from '../users/types';
|
||||
import { UserType } from '../user/types';
|
||||
|
||||
export enum Permission {
|
||||
Read = 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
@@ -23,9 +23,11 @@ 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(file).then(mod => {
|
||||
return import(pathToFileURL(file).href).then(mod => {
|
||||
const migration = mod[Object.keys(mod)[0]];
|
||||
|
||||
return {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { loop } from './utils/loop';
|
||||
|
||||
export class Oauth1710319359062 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await loop(async (skip, take) => {
|
||||
const oldRecords = await db.deprecatedNextAuthAccount.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
providerAccountId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
await db.connectedAccount.createMany({
|
||||
data: oldRecords.map(record => ({
|
||||
userId: record.userId,
|
||||
provider: record.provider,
|
||||
scope: record.scope,
|
||||
providerAccountId: record.providerAccountId,
|
||||
accessToken: record.access_token,
|
||||
refreshToken: record.refresh_token,
|
||||
expiresAt: record.expires_at
|
||||
? new Date(record.expires_at * 1000)
|
||||
: null,
|
||||
})),
|
||||
});
|
||||
|
||||
return oldRecords.length;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(db: PrismaClient) {
|
||||
await db.connectedAccount.deleteMany({});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../fundamentals';
|
||||
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) {
|
||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
||||
const config = ref.get(Config, { strict: false });
|
||||
const crypto = ref.get(CryptoHelper, { strict: false });
|
||||
const user = ref.get(UserService, { strict: false });
|
||||
if (config.isSelfhosted) {
|
||||
if (
|
||||
!process.env.AFFINE_ADMIN_EMAIL ||
|
||||
@@ -17,13 +19,12 @@ export class SelfHostAdmin1605053000403 {
|
||||
'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.'
|
||||
);
|
||||
}
|
||||
await db.user.create({
|
||||
data: {
|
||||
name: 'AFFINE First User',
|
||||
email: process.env.AFFINE_ADMIN_EMAIL,
|
||||
emailVerified: new Date(),
|
||||
password: await hash(process.env.AFFINE_ADMIN_PASSWORD),
|
||||
},
|
||||
await user.findOrCreateUser(process.env.AFFINE_ADMIN_EMAIL, {
|
||||
name: 'AFFINE First User',
|
||||
emailVerifiedAt: new Date(),
|
||||
password: await crypto.encryptPassword(
|
||||
process.env.AFFINE_ADMIN_PASSWORD
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
13
packages/backend/server/src/data/migrations/utils/loop.ts
Normal file
13
packages/backend/server/src/data/migrations/utils/loop.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export async function loop(
|
||||
batchFn: (skip: number, take: number) => Promise<number>,
|
||||
chunkSize: number = 100
|
||||
) {
|
||||
let turn = 0;
|
||||
let last = chunkSize;
|
||||
|
||||
while (last === chunkSize) {
|
||||
last = await batchFn(chunkSize * turn, chunkSize);
|
||||
|
||||
turn++;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -87,6 +81,22 @@ export interface AFFiNEConfig {
|
||||
sync: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application secrets for authentication and data encryption
|
||||
*/
|
||||
secrets: {
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment environment
|
||||
*/
|
||||
@@ -205,66 +215,50 @@ export interface AFFiNEConfig {
|
||||
*/
|
||||
auth: {
|
||||
/**
|
||||
* Application access token expiration time
|
||||
*/
|
||||
readonly accessTokenExpiresIn: number;
|
||||
/**
|
||||
* Application refresh token expiration time
|
||||
*/
|
||||
readonly refreshTokenExpiresIn: number;
|
||||
/**
|
||||
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
|
||||
* Defaults to 60 if omitted.
|
||||
*/
|
||||
readonly leeway: number;
|
||||
/**
|
||||
* Application public key
|
||||
* The minimum and maximum length of the password when registering new users
|
||||
*
|
||||
* @default [8,32]
|
||||
*/
|
||||
readonly publicKey: string;
|
||||
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
|
||||
*
|
||||
* @default 15 days
|
||||
*/
|
||||
ttl: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
* Application access token config
|
||||
*/
|
||||
readonly privateKey: string;
|
||||
/**
|
||||
* whether allow user to signup with email directly
|
||||
*/
|
||||
enableSignup: boolean;
|
||||
/**
|
||||
* whether allow user to signup by oauth providers
|
||||
*/
|
||||
enableOauth: boolean;
|
||||
/**
|
||||
* NEXTAUTH_SECRET
|
||||
*/
|
||||
nextAuthSecret: string;
|
||||
/**
|
||||
* all available oauth providers
|
||||
*/
|
||||
oauthProviders: Partial<
|
||||
Record<
|
||||
ExternalAccount,
|
||||
{
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* uri to start oauth flow
|
||||
*/
|
||||
authorizationUri?: string;
|
||||
/**
|
||||
* uri to authenticate `access_token` when user is redirected back from oauth provider with `code`
|
||||
*/
|
||||
accessTokenUri?: string;
|
||||
/**
|
||||
* uri to get user info with authenticated `access_token`
|
||||
*/
|
||||
userInfoUri?: string;
|
||||
args?: Record<string, any>;
|
||||
}
|
||||
>
|
||||
>;
|
||||
accessToken: {
|
||||
/**
|
||||
* Application access token expiration time in seconds
|
||||
*
|
||||
* @default 7 days
|
||||
*/
|
||||
ttl: number;
|
||||
/**
|
||||
* Application refresh token expiration time in seconds
|
||||
*
|
||||
* @default 30 days
|
||||
*/
|
||||
refreshTokenTtl: number;
|
||||
};
|
||||
captcha: {
|
||||
/**
|
||||
* whether to enable captcha
|
||||
@@ -338,6 +332,11 @@ export interface AFFiNEConfig {
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
telemetry: {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export * from './storage';
|
||||
|
||||
@@ -3,16 +3,10 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { merge } from 'lodash-es';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
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';
|
||||
|
||||
@@ -23,10 +17,13 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
|
||||
const jwtKeyPair = (function () {
|
||||
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const ONE_DAY_IN_SEC = 60 * 60 * 24;
|
||||
|
||||
const keyPair = (function () {
|
||||
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',
|
||||
})
|
||||
@@ -36,7 +33,7 @@ const jwtKeyPair = (function () {
|
||||
})
|
||||
.toString('utf8');
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
key: Buffer.from(AFFINE_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
@@ -76,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',
|
||||
@@ -97,23 +103,19 @@ 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;
|
||||
},
|
||||
secrets: {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
},
|
||||
featureFlags: {
|
||||
earlyAccessPreview: false,
|
||||
syncClientVersionCheck: false,
|
||||
@@ -145,11 +147,17 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
playground: true,
|
||||
},
|
||||
auth: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
accessTokenExpiresIn: parse('1h')! / 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||
leeway: 60,
|
||||
password: {
|
||||
minLength: node.prod ? 8 : 1,
|
||||
maxLength: 32,
|
||||
},
|
||||
session: {
|
||||
ttl: 15 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
accessToken: {
|
||||
ttl: 7 * ONE_DAY_IN_SEC,
|
||||
refreshTokenTtl: 30 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
captcha: {
|
||||
enable: false,
|
||||
turnstile: {
|
||||
@@ -159,14 +167,6 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
privateKey: jwtKeyPair.privateKey,
|
||||
publicKey: jwtKeyPair.publicKey,
|
||||
enableSignup: true,
|
||||
enableOauth: false,
|
||||
get nextAuthSecret() {
|
||||
return this.privateKey;
|
||||
},
|
||||
oauthProviders: {},
|
||||
},
|
||||
storage: getDefaultAFFiNEStorageConfig(),
|
||||
rateLimiter: {
|
||||
@@ -187,11 +187,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
metrics: {
|
||||
enabled: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: isSelfhosted && !process.env.DISABLE_SERVER_TELEMETRY,
|
||||
token: '389c0615a69b57cca7d3fa0a4824c930',
|
||||
},
|
||||
plugins: {
|
||||
enabled: [],
|
||||
enabled: new Set(),
|
||||
use(plugin, config) {
|
||||
this[plugin] = merge(this[plugin], config || {});
|
||||
this.enabled.push(plugin);
|
||||
this.enabled.add(plugin);
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './payment-required';
|
||||
export * from './too-many-requests';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class TooManyRequestsException extends HttpException {
|
||||
constructor(desc?: string, code: string = 'Too Many Requests') {
|
||||
super(
|
||||
HttpException.createBody(
|
||||
desc ?? code,
|
||||
code,
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
),
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import { GraphQLError } from 'graphql';
|
||||
import { Config } from '../config';
|
||||
import { GQLLoggerPlugin } from './logger-plugin';
|
||||
|
||||
export type GraphqlContext = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
isAdminQuery: boolean;
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -30,7 +36,13 @@ import { GQLLoggerPlugin } from './logger-plugin';
|
||||
: '../../../schema.gql'
|
||||
),
|
||||
sortSchema: true,
|
||||
context: ({ req, res }: { req: Request; res: Response }) => ({
|
||||
context: ({
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
}): GraphqlContext => ({
|
||||
req,
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ConfigModule } from '../../config';
|
||||
import { CryptoHelper } from '../crypto';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
crypto: CryptoHelper;
|
||||
}>;
|
||||
|
||||
const key = `-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
|
||||
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
const privateKey = createPrivateKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
.export({
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
const publicKey = createPublicKey({
|
||||
key,
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
secrets: {
|
||||
publicKey,
|
||||
privateKey,
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [CryptoHelper],
|
||||
}).compile();
|
||||
|
||||
t.context.crypto = module.get(CryptoHelper);
|
||||
});
|
||||
|
||||
test('should be able to sign and verify', t => {
|
||||
const data = 'hello world';
|
||||
const signature = t.context.crypto.sign(data);
|
||||
t.true(t.context.crypto.verify(data, signature));
|
||||
t.false(t.context.crypto.verify(data, 'fake-signature'));
|
||||
});
|
||||
|
||||
test('should be able to encrypt and decrypt', t => {
|
||||
const data = 'top secret';
|
||||
const stub = Sinon.stub(t.context.crypto, 'randomBytes').returns(
|
||||
Buffer.alloc(12, 0)
|
||||
);
|
||||
|
||||
const encrypted = t.context.crypto.encrypt(data);
|
||||
const decrypted = t.context.crypto.decrypt(encrypted);
|
||||
|
||||
// we are using a stub to make sure the iv is always 0,
|
||||
// the encrypted result will always be the same
|
||||
t.is(encrypted, 'AAAAAAAAAAAAAAAAWUDlJRhzP+SZ3avvmLcgnou+q4E11w==');
|
||||
t.is(decrypted, data);
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('should be able to get random bytes', t => {
|
||||
const bytes = t.context.crypto.randomBytes();
|
||||
t.is(bytes.length, 12);
|
||||
const bytes2 = t.context.crypto.randomBytes();
|
||||
|
||||
t.notDeepEqual(bytes, bytes2);
|
||||
});
|
||||
|
||||
test('should be able to digest', t => {
|
||||
const data = 'hello world';
|
||||
const hash = t.context.crypto.sha256(data).toString('base64');
|
||||
t.is(hash, 'uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek=');
|
||||
});
|
||||
|
||||
test('should be able to safe compare', t => {
|
||||
t.true(t.context.crypto.compare('abc', 'abc'));
|
||||
t.false(t.context.crypto.compare('abc', 'def'));
|
||||
});
|
||||
|
||||
test('should be able to hash and verify password', async t => {
|
||||
const password = 'mySecurePassword';
|
||||
const hash = await t.context.crypto.encryptPassword(password);
|
||||
t.true(await t.context.crypto.verifyPassword(password, hash));
|
||||
t.false(await t.context.crypto.verifyPassword('wrong-password', hash));
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { ConfigModule } from '../../config';
|
||||
import { URLHelper } from '../url';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
url: URLHelper;
|
||||
}>;
|
||||
|
||||
test.beforeEach(async t => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
host: 'app.affine.local',
|
||||
port: 3010,
|
||||
https: true,
|
||||
}),
|
||||
],
|
||||
providers: [URLHelper],
|
||||
}).compile();
|
||||
|
||||
t.context.url = module.get(URLHelper);
|
||||
});
|
||||
|
||||
test('can get home page', t => {
|
||||
t.is(t.context.url.home, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can stringify query', t => {
|
||||
t.is(t.context.url.stringify({ a: 1, b: 2 }), 'a=1&b=2');
|
||||
t.is(t.context.url.stringify({ a: 1, b: '/path' }), 'a=1&b=%2Fpath');
|
||||
});
|
||||
|
||||
test('can create link', t => {
|
||||
t.is(t.context.url.link('/path'), 'https://app.affine.local/path');
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: 2 }),
|
||||
'https://app.affine.local/path?a=1&b=2'
|
||||
);
|
||||
t.is(
|
||||
t.context.url.link('/path', { a: 1, b: '/path' }),
|
||||
'https://app.affine.local/path?a=1&b=%2Fpath'
|
||||
);
|
||||
});
|
||||
|
||||
test('can safe redirect', t => {
|
||||
const res = {
|
||||
redirect: (to: string) => to,
|
||||
} as any;
|
||||
|
||||
const spy = Sinon.spy(res, 'redirect');
|
||||
function allow(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(to));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
[
|
||||
'https://app.affine.local',
|
||||
'https://app.affine.local/path',
|
||||
'https://app.affine.local/path?query=1',
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
115
packages/backend/server/src/fundamentals/helpers/crypto.ts
Normal file
115
packages/backend/server/src/fundamentals/helpers/crypto.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createSign,
|
||||
createVerify,
|
||||
randomBytes,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
hash as hashPassword,
|
||||
verify as verifyPassword,
|
||||
} from '@node-rs/argon2';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 12;
|
||||
|
||||
@Injectable()
|
||||
export class CryptoHelper {
|
||||
keyPair: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
sha256: {
|
||||
publicKey: Buffer;
|
||||
privateKey: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
constructor(config: Config) {
|
||||
this.keyPair = {
|
||||
publicKey: Buffer.from(config.secrets.publicKey, 'utf8'),
|
||||
privateKey: Buffer.from(config.secrets.privateKey, 'utf8'),
|
||||
sha256: {
|
||||
publicKey: this.sha256(config.secrets.publicKey),
|
||||
privateKey: this.sha256(config.secrets.privateKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
sign(data: string) {
|
||||
const sign = createSign('rsa-sha256');
|
||||
sign.update(data, 'utf-8');
|
||||
sign.end();
|
||||
return sign.sign(this.keyPair.privateKey, 'base64');
|
||||
}
|
||||
|
||||
verify(data: string, signature: string) {
|
||||
const verify = createVerify('rsa-sha256');
|
||||
verify.update(data, 'utf-8');
|
||||
verify.end();
|
||||
return verify.verify(this.keyPair.privateKey, signature, 'base64');
|
||||
}
|
||||
|
||||
encrypt(data: string) {
|
||||
const iv = this.randomBytes();
|
||||
const cipher = createCipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{
|
||||
authTagLength: AUTH_TAG_LENGTH,
|
||||
}
|
||||
);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(data, 'utf-8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, authTag, encrypted]).toString('base64');
|
||||
}
|
||||
|
||||
decrypt(encrypted: string) {
|
||||
const buf = Buffer.from(encrypted, 'base64');
|
||||
const iv = buf.subarray(0, NONCE_LENGTH);
|
||||
const authTag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const encryptedToken = buf.subarray(NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
this.keyPair.sha256.privateKey,
|
||||
iv,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrepted = decipher.update(encryptedToken, void 0, 'utf8');
|
||||
return decrepted + decipher.final('utf8');
|
||||
}
|
||||
|
||||
encryptPassword(password: string) {
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hash: string) {
|
||||
return verifyPassword(hash, password);
|
||||
}
|
||||
|
||||
compare(lhs: string, rhs: string) {
|
||||
if (lhs.length !== rhs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(Buffer.from(lhs), Buffer.from(rhs));
|
||||
}
|
||||
|
||||
randomBytes(length = NONCE_LENGTH) {
|
||||
return randomBytes(length);
|
||||
}
|
||||
|
||||
sha256(data: string) {
|
||||
return createHash('sha256').update(data).digest();
|
||||
}
|
||||
}
|
||||
13
packages/backend/server/src/fundamentals/helpers/index.ts
Normal file
13
packages/backend/server/src/fundamentals/helpers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from './crypto';
|
||||
import { URLHelper } from './url';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [URLHelper, CryptoHelper],
|
||||
exports: [URLHelper, CryptoHelper],
|
||||
})
|
||||
export class HelpersModule {}
|
||||
|
||||
export { CryptoHelper, URLHelper };
|
||||
54
packages/backend/server/src/fundamentals/helpers/url.ts
Normal file
54
packages/backend/server/src/fundamentals/helpers/url.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { Config } from '../config';
|
||||
|
||||
@Injectable()
|
||||
export class URLHelper {
|
||||
redirectAllowHosts: string[];
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.redirectAllowHosts = [this.config.baseUrl];
|
||||
}
|
||||
|
||||
get home() {
|
||||
return this.config.baseUrl;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
return new URLSearchParams(query).toString();
|
||||
}
|
||||
|
||||
link(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(
|
||||
this.config.baseUrl + (path.startsWith('/') ? path : '/' + path)
|
||||
);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.config.baseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
if (
|
||||
hostURL.origin === finalTo.origin &&
|
||||
finalTo.pathname.startsWith(hostURL.pathname)
|
||||
) {
|
||||
return res.redirect(finalTo.toString().replace(/\/$/, ''));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// just ignore invalid url
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.home);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user