mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 01:23:46 +00:00
Compare commits
372 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
305241771c | ||
|
|
55db9f9719 | ||
|
|
e3c3d1ac69 | ||
|
|
bd0279730c | ||
|
|
988f3a39f8 | ||
|
|
f65380f847 | ||
|
|
a62b7f0024 | ||
|
|
4512a1a91d | ||
|
|
af7d44164c | ||
|
|
6dbcb62da7 | ||
|
|
239de4c283 | ||
|
|
544236f1a0 | ||
|
|
145872b9f4 | ||
|
|
90c00b6db9 | ||
|
|
585003640f | ||
|
|
9440dc8dd5 | ||
|
|
9fe77baf05 | ||
|
|
133888d760 | ||
|
|
9160469a18 | ||
|
|
71ddb1f841 | ||
|
|
4f718cffbf | ||
|
|
b9d84fe007 | ||
|
|
ad970837ec | ||
|
|
d168128174 | ||
|
|
2919d4912c | ||
|
|
dcb9d75db7 | ||
|
|
ccac7a883c | ||
|
|
ade8db2aec | ||
|
|
07d4c476c2 | ||
|
|
db3533724b | ||
|
|
4868f6e611 | ||
|
|
08a0572d4e | ||
|
|
e97ac11d0f | ||
|
|
7f9d321d9c | ||
|
|
85a02b74f9 | ||
|
|
53eb4aca8d | ||
|
|
b15294d80c | ||
|
|
3590b53f40 | ||
|
|
1f50c1b890 | ||
|
|
b50c57a3fa | ||
|
|
063c206289 | ||
|
|
242c41b440 | ||
|
|
7082f7ea7a | ||
|
|
15042394be | ||
|
|
e4b816f153 | ||
|
|
7103b2e594 | ||
|
|
dca88e24fe | ||
|
|
0f1409756e | ||
|
|
2f784ae539 | ||
|
|
5ede985a3a | ||
|
|
024e5500f6 | ||
|
|
5dd7382693 | ||
|
|
5f16cb400d | ||
|
|
4591b3391e | ||
|
|
c2f93f9512 | ||
|
|
c850dbb2b7 | ||
|
|
7a35b78772 | ||
|
|
2f441d9335 | ||
|
|
0739e10683 | ||
|
|
22187f964a | ||
|
|
cf7b026832 | ||
|
|
e6818b4f14 | ||
|
|
aab9925aa1 | ||
|
|
86218d87c2 | ||
|
|
de4084495b | ||
|
|
13a2562282 | ||
|
|
556956ced2 | ||
|
|
bf6c9a5955 | ||
|
|
9ef8829ef1 | ||
|
|
de91027852 | ||
|
|
7235779b02 | ||
|
|
ba356f4412 | ||
|
|
602d932065 | ||
|
|
8dfa601771 | ||
|
|
481a2269f8 | ||
|
|
555f203be6 | ||
|
|
5c1f78afd4 | ||
|
|
d6ad7d566f | ||
|
|
b79d13bcc8 | ||
|
|
a0ce75c902 | ||
|
|
e8285289fe | ||
|
|
cc7740d8d3 | ||
|
|
61870c04d0 | ||
|
|
10df1fb4b7 | ||
|
|
0bc09a9333 | ||
|
|
f0d127fa29 | ||
|
|
fc729d6a32 | ||
|
|
ef7ba273ab | ||
|
|
b8b30e79e5 | ||
|
|
2a6ea3c9c6 | ||
|
|
c62d79ab14 | ||
|
|
27d0fc5108 | ||
|
|
40e381e272 | ||
|
|
15e99c7819 | ||
|
|
3870801ebb | ||
|
|
0957c30e74 | ||
|
|
90e4a9b181 | ||
|
|
1997f24414 | ||
|
|
3f8fe5cfae | ||
|
|
8c4a42f0e6 | ||
|
|
4d484ea814 | ||
|
|
3bbb657a78 | ||
|
|
39acb51d87 | ||
|
|
d72dbe682c | ||
|
|
824be0d4c1 | ||
|
|
fbf676002f | ||
|
|
e877f20955 | ||
|
|
f4f84d2793 | ||
|
|
34b6a3bf1f | ||
|
|
eca484dc28 | ||
|
|
855d555480 | ||
|
|
d51fd8b54b | ||
|
|
f020cd353e | ||
|
|
6f217f61b6 | ||
|
|
4f07a2cc2a | ||
|
|
ae230354c6 | ||
|
|
08fa356a02 | ||
|
|
c8f418f9c5 | ||
|
|
c6c4ed9711 | ||
|
|
2c92e97c48 | ||
|
|
fa2305b0e2 | ||
|
|
3b3b7ec054 | ||
|
|
81462fe000 | ||
|
|
03af538989 | ||
|
|
33bb2b5fcd | ||
|
|
742c10ac94 | ||
|
|
4af6223dc3 | ||
|
|
e892d55134 | ||
|
|
827c952e9f | ||
|
|
f15d1911ee | ||
|
|
a7ea74923a | ||
|
|
595b904d24 | ||
|
|
6f31d5ee6a | ||
|
|
a5662e3de2 | ||
|
|
dcf766f0ee | ||
|
|
aeb666f95e | ||
|
|
6b47c6beda | ||
|
|
3bb8253e13 | ||
|
|
6344456d37 | ||
|
|
bd9cb41f8a | ||
|
|
6f9a4bb01a | ||
|
|
59fe5fb57a | ||
|
|
7baa260e97 | ||
|
|
092c639b0a | ||
|
|
ad746b6376 | ||
|
|
10bc0d1e29 | ||
|
|
4df334bfdb | ||
|
|
2d74e4c340 | ||
|
|
16096978d8 | ||
|
|
61f2617128 | ||
|
|
2278cfc9ce | ||
|
|
a92515b5aa | ||
|
|
cffaf815e1 | ||
|
|
532a628989 | ||
|
|
fe89ecb1d3 | ||
|
|
45b3b833d4 | ||
|
|
f51da066a8 | ||
|
|
76f6d91cdd | ||
|
|
a93364af01 | ||
|
|
820ecba7cc | ||
|
|
daceed922b | ||
|
|
b38c46649f | ||
|
|
0918730274 | ||
|
|
e92eb339e4 | ||
|
|
d54a7467c8 | ||
|
|
5e9fa9aff9 | ||
|
|
8d602d4398 | ||
|
|
cd9205a3c5 | ||
|
|
2482c3c266 | ||
|
|
6865a52172 | ||
|
|
7b3673ae82 | ||
|
|
e085b927f6 | ||
|
|
f85a321bfa | ||
|
|
1dc254a755 | ||
|
|
f24c0caaea | ||
|
|
92be6b2ff7 | ||
|
|
bf0cba31d3 | ||
|
|
95743a3b14 | ||
|
|
aa124638bc | ||
|
|
a557fd3277 | ||
|
|
dbd678cc31 | ||
|
|
b99e8b1e8a | ||
|
|
e0c6d23691 | ||
|
|
1a8bbc6951 | ||
|
|
1fdfa834a0 | ||
|
|
78429166da | ||
|
|
b4212d54ed | ||
|
|
ab3d6c9cc8 | ||
|
|
671fa1149d | ||
|
|
e8fdce514f | ||
|
|
a085e507b4 | ||
|
|
ddf72733e1 | ||
|
|
0d711667a8 | ||
|
|
7c0a686cd9 | ||
|
|
5b0f56399c | ||
|
|
d34c5c42ef | ||
|
|
9746ddb5e0 | ||
|
|
f9903fd748 | ||
|
|
98e35384a6 | ||
|
|
be36e033f2 | ||
|
|
84ad23625f | ||
|
|
bcc66422fd | ||
|
|
b379aa0a91 | ||
|
|
314fa064ab | ||
|
|
bbf796faaf | ||
|
|
fa1e7c6be0 | ||
|
|
d84eea85b6 | ||
|
|
501fa0bb91 | ||
|
|
dddfcdbabb | ||
|
|
ce9a3c82ee | ||
|
|
eb71471820 | ||
|
|
6c3e13316e | ||
|
|
4edf0b1d85 | ||
|
|
96df708e3a | ||
|
|
09201d42a0 | ||
|
|
390852f8be | ||
|
|
98258b0211 | ||
|
|
ea718d30e9 | ||
|
|
b3ec3a2b3e | ||
|
|
0fe672efa5 | ||
|
|
d216606193 | ||
|
|
9b89c9ed38 | ||
|
|
006c260e5f | ||
|
|
e3bd8562b8 | ||
|
|
e14e110f03 | ||
|
|
e2dbac6bf8 | ||
|
|
54fc1197ad | ||
|
|
5307a55f8a | ||
|
|
7062790488 | ||
|
|
32a5943a90 | ||
|
|
7e8c33a897 | ||
|
|
22ec26440b | ||
|
|
729631ea72 | ||
|
|
33762423bb | ||
|
|
3189f42e76 | ||
|
|
19dd724f50 | ||
|
|
24cb0d3757 | ||
|
|
a3ca41fd6a | ||
|
|
3bf80d86d8 | ||
|
|
e6a2843ddf | ||
|
|
ec8b2cc32a | ||
|
|
a7427a4f8d | ||
|
|
764527ffc6 | ||
|
|
eef4e821e5 | ||
|
|
89bd8a032c | ||
|
|
b8a96359bf | ||
|
|
e2c95731ab | ||
|
|
dee0793179 | ||
|
|
4154f2f160 | ||
|
|
cae7c7383b | ||
|
|
a11cb6b0cd | ||
|
|
b1eb26507d | ||
|
|
36dcbc1ef7 | ||
|
|
ef01b6255e | ||
|
|
6147cd30b5 | ||
|
|
5afa3f953f | ||
|
|
b34cfe6c7f | ||
|
|
41bc683eed | ||
|
|
ab26e0f360 | ||
|
|
8dc3fd2a99 | ||
|
|
3464e30229 | ||
|
|
ca9a16b728 | ||
|
|
44b0ea2b6c | ||
|
|
b75da1f3e0 | ||
|
|
17a5900575 | ||
|
|
e6ec506226 | ||
|
|
f20b78b824 | ||
|
|
5855f72b5b | ||
|
|
1a509d18a5 | ||
|
|
38dde7f5b7 | ||
|
|
79e1e8dd2f | ||
|
|
51429c957b | ||
|
|
f78416021a | ||
|
|
d791ab73ba | ||
|
|
6abb401a2c | ||
|
|
b99b964a78 | ||
|
|
10297766b8 | ||
|
|
7095ca1be6 | ||
|
|
5bd9c7a2a3 | ||
|
|
1439d00b61 | ||
|
|
de81527e29 | ||
|
|
fa4e4c738a | ||
|
|
928e133655 | ||
|
|
a5f9bfdda9 | ||
|
|
ecff441516 | ||
|
|
c8242fc066 | ||
|
|
84c79f4dcf | ||
|
|
616d7073c9 | ||
|
|
f67108c6f7 | ||
|
|
db0837936a | ||
|
|
01fc1ea835 | ||
|
|
412b919ec6 | ||
|
|
d43fcdcdd6 | ||
|
|
06534bbc06 | ||
|
|
798af4efee | ||
|
|
235882f6f3 | ||
|
|
5b8b2bbf48 | ||
|
|
03be1d66f9 | ||
|
|
c96fb46751 | ||
|
|
002b9e80f8 | ||
|
|
ea0059fa1b | ||
|
|
b65c01c5e1 | ||
|
|
1b633b5135 | ||
|
|
b13151b480 | ||
|
|
004390f40c | ||
|
|
fe38a3780f | ||
|
|
da3cbc69cf | ||
|
|
622239fd41 | ||
|
|
d7ced4a5d9 | ||
|
|
d853fc879d | ||
|
|
bffbb1ea9f | ||
|
|
0dfcc97c52 | ||
|
|
4b30fbc1e2 | ||
|
|
1a269a4b52 | ||
|
|
e417c4cd44 | ||
|
|
40b35b7bc2 | ||
|
|
638fc62601 | ||
|
|
9d296c4b62 | ||
|
|
2ca77d9170 | ||
|
|
bd9c929d05 | ||
|
|
5759c15de3 | ||
|
|
94e2122703 | ||
|
|
0b682c01f0 | ||
|
|
f4fdbbe6dd | ||
|
|
aa4fa44e1a | ||
|
|
8d9a178428 | ||
|
|
0dd0432531 | ||
|
|
aff166a0ef | ||
|
|
5ba9e2e9b1 | ||
|
|
50dcce891b | ||
|
|
cf42faa83b | ||
|
|
b356ddbe6e | ||
|
|
306cf2ae6f | ||
|
|
2d2a84927d | ||
|
|
8ed0ceb63e | ||
|
|
a365acbd87 | ||
|
|
13b51c7b22 | ||
|
|
a440e85ffe | ||
|
|
5552c02e4a | ||
|
|
88d4351c28 | ||
|
|
919e40f28e | ||
|
|
950e163314 | ||
|
|
0302a85585 | ||
|
|
937b8bf166 | ||
|
|
02564a8d8c | ||
|
|
ae00dfef08 | ||
|
|
5e1528b50b | ||
|
|
7c2f60c441 | ||
|
|
22a8a2663e | ||
|
|
0c42849bc3 | ||
|
|
535254fdf6 | ||
|
|
f511b02bf9 | ||
|
|
f8fee55b3d | ||
|
|
3eaddd6e42 | ||
|
|
6278523642 | ||
|
|
1c1c1836d3 | ||
|
|
d066da3e8a | ||
|
|
f3c9593606 | ||
|
|
7a657b540b | ||
|
|
0f5ae77032 | ||
|
|
fdc33bd3ec | ||
|
|
f50e240e3d | ||
|
|
609766d898 | ||
|
|
3b8345ea5a | ||
|
|
29e7fa1371 | ||
|
|
278336168f | ||
|
|
96cdb041c6 | ||
|
|
f05b51ab49 | ||
|
|
41c7215ef1 | ||
|
|
d898dae280 | ||
|
|
d5c93f10ac | ||
|
|
7fddd14f72 |
@@ -1,14 +1,8 @@
|
||||
ENABLE_PLUGIN=
|
||||
ENABLE_TEST_PROPERTIES=
|
||||
ENABLE_BC_PROVIDER=
|
||||
CHANGELOG_URL=
|
||||
ENABLE_PRELOADING=
|
||||
ENABLE_NEW_SETTING_MODAL=
|
||||
ENABLE_SQLITE_PROVIDER=
|
||||
ENABLE_NEW_SETTING_UNSTABLE_API=
|
||||
ENABLE_NOTIFICATION_CENTER=
|
||||
ENABLE_CLOUD=
|
||||
ENABLE_MOVE_DATABASE=
|
||||
SHOULD_REPORT_TRACE=
|
||||
TRACE_REPORT_ENDPOINT=
|
||||
CAPTCHA_SITE_KEY=
|
||||
ENABLE_CAPTCHA=
|
||||
CAPTCHA_SITE_KEY=
|
||||
ENABLE_ENHANCE_SHARE_MODE=
|
||||
ALLOW_LOCAL_WORKSPACE=
|
||||
DEBUG_JOTAI=
|
||||
@@ -157,11 +157,6 @@ const config = {
|
||||
message: "Import from '@blocksuite/global/utils'",
|
||||
importNames: ['assertExists', 'assertEquals'],
|
||||
},
|
||||
{
|
||||
group: ['react-router-dom'],
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -252,7 +247,7 @@ const config = {
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: 'useAsyncCallback',
|
||||
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
3
.github/actions/deploy/deploy.mjs
vendored
3
.github/actions/deploy/deploy.mjs
vendored
@@ -14,6 +14,7 @@ const {
|
||||
R2_ACCESS_KEY_ID,
|
||||
R2_SECRET_ACCESS_KEY,
|
||||
CAPTCHA_TURNSTILE_SECRET,
|
||||
METRICS_CUSTOMER_IO_TOKEN,
|
||||
COPILOT_OPENAI_API_KEY,
|
||||
COPILOT_FAL_API_KEY,
|
||||
COPILOT_UNSPLASH_API_KEY,
|
||||
@@ -117,6 +118,8 @@ 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.metrics.enabled=true`,
|
||||
`--set-string graphql.app.metrics.customerIo.token="${METRICS_CUSTOMER_IO_TOKEN}"`,
|
||||
`--set graphql.app.experimental.enableJwstCodec=${namespace === 'dev'}`,
|
||||
`--set graphql.app.features.earlyAccessPreview=false`,
|
||||
`--set graphql.app.features.syncClientVersionCheck=true`,
|
||||
|
||||
1
.github/deployment/front/Dockerfile
vendored
1
.github/deployment/front/Dockerfile
vendored
@@ -1,6 +1,7 @@
|
||||
FROM openresty/openresty:1.25.3.1-0-buster
|
||||
WORKDIR /app
|
||||
COPY ./packages/frontend/web/dist ./dist
|
||||
COPY ./packages/frontend/admin/dist ./admin
|
||||
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
|
||||
|
||||
|
||||
29
.github/deployment/front/affine.nginx.conf
vendored
29
.github/deployment/front/affine.nginx.conf
vendored
@@ -1,13 +1,24 @@
|
||||
server {
|
||||
listen 8080;
|
||||
root /app/dist;
|
||||
listen 8080;
|
||||
location /admin {
|
||||
root /app/;
|
||||
index index.html;
|
||||
try_files $uri/index.html $uri/ $uri /admin/index.html;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location ~ ^/(_plugin|assets|imgs|js|plugins|static)/ {
|
||||
root /app/dist/;
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
location / {
|
||||
root /app/dist/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
}
|
||||
|
||||
1
.github/deployment/node/Dockerfile
vendored
1
.github/deployment/node/Dockerfile
vendored
@@ -2,6 +2,7 @@ FROM node:20-bookworm-slim
|
||||
|
||||
COPY ./packages/backend/server /app
|
||||
COPY ./packages/frontend/web/dist /app/static
|
||||
COPY ./packages/frontend/admin/dist /app/static/admin
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.14.0"
|
||||
appVersion: "0.15.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.14.0"
|
||||
appVersion: "0.15.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -191,6 +191,13 @@ spec:
|
||||
name: "{{ .Values.app.oauth.github.secretName }}"
|
||||
key: clientSecret
|
||||
{{ end }}
|
||||
{{ if .Values.app.metrics.enabled }}
|
||||
- name: METRICS_CUSTOMER_IO_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.metrics.secretName }}"
|
||||
key: customerIoSecret
|
||||
{{ end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
|
||||
9
.github/helm/affine/charts/graphql/templates/metrics-secret.yaml
vendored
Normal file
9
.github/helm/affine/charts/graphql/templates/metrics-secret.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{{- if .Values.app.metrics.enabled -}}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: "{{ .Values.app.metrics.secretName }}"
|
||||
type: Opaque
|
||||
data:
|
||||
customerIoSecret: {{ .Values.app.metrics.customerIo.token | b64enc }}
|
||||
{{- end }}
|
||||
@@ -22,6 +22,8 @@ spec:
|
||||
value: "{{ .Values.env }}"
|
||||
- name: AFFINE_ENV
|
||||
value: "{{ .Release.Namespace }}"
|
||||
- name: DEPLOYMENT_TYPE
|
||||
value: "affine"
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -20,12 +20,12 @@ app:
|
||||
doc:
|
||||
mergeInterval: "3000"
|
||||
captcha:
|
||||
enable: false
|
||||
enabled: false
|
||||
secretName: captcha
|
||||
turnstile:
|
||||
secret: ''
|
||||
copilot:
|
||||
enable: false
|
||||
enabled: false
|
||||
secretName: copilot
|
||||
openai:
|
||||
key: ''
|
||||
@@ -54,6 +54,11 @@ app:
|
||||
user: ''
|
||||
password: ''
|
||||
sender: 'noreply@toeverything.info'
|
||||
metrics:
|
||||
enabled: false
|
||||
secretName: 'metrics'
|
||||
customerIo:
|
||||
token: ''
|
||||
payment:
|
||||
stripe:
|
||||
secretName: 'stripe'
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.14.0"
|
||||
appVersion: "0.15.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
7
.github/helm/affine/templates/ingress.yaml
vendored
7
.github/helm/affine/templates/ingress.yaml
vendored
@@ -74,4 +74,11 @@ spec:
|
||||
name: affine-web
|
||||
port:
|
||||
number: {{ .Values.web.service.port }}
|
||||
- path: /js/worker.(.+).js
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: affine-web
|
||||
port:
|
||||
number: {{ .Values.web.service.port }}
|
||||
{{- end }}
|
||||
|
||||
40
.github/workflows/build-server-image.yml
vendored
40
.github/workflows/build-server-image.yml
vendored
@@ -6,6 +6,11 @@ on:
|
||||
flavor:
|
||||
type: string
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flavor:
|
||||
type: string
|
||||
required: false
|
||||
|
||||
env:
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
@@ -53,7 +58,6 @@ jobs:
|
||||
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
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
@@ -66,6 +70,31 @@ jobs:
|
||||
path: ./packages/frontend/web/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-admin-selfhost:
|
||||
name: Build @affine/admin 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/admin --skip-nx-cache
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
PUBLIC_PATH: '/admin/'
|
||||
SELF_HOSTED: true
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
- name: Upload admin artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-admin
|
||||
path: ./packages/frontend/admin/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-server-native:
|
||||
name: Build Server native - ${{ matrix.targets.name }}
|
||||
runs-on: ubuntu-latest
|
||||
@@ -108,6 +137,7 @@ jobs:
|
||||
needs:
|
||||
- build-server
|
||||
- build-web-selfhost
|
||||
- build-admin-selfhost
|
||||
- build-server-native
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -171,6 +201,12 @@ jobs:
|
||||
name: selfhost-web
|
||||
path: ./packages/frontend/web/dist
|
||||
|
||||
- name: Download selfhost admin artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: selfhost-admin
|
||||
path: ./packages/frontend/admin/dist
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: |
|
||||
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
|
||||
@@ -185,7 +221,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-version
|
||||
|
||||
- name: Build graphql Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
6
.github/workflows/build-test.yml
vendored
6
.github/workflows/build-test.yml
vendored
@@ -165,7 +165,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-e2e-migration
|
||||
path: ./tests/affine-migration/test-results
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
unit-test:
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
env:
|
||||
CARGO_TARGET_DIR: '${{ github.workspace }}/target'
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
|
||||
COPILOT_OPENAI_API_KEY: 'use_fake_openai_api_key'
|
||||
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v4
|
||||
@@ -449,7 +449,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results-e2e-server
|
||||
path: ./tests/affine-cloud/test-results
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
desktop-test:
|
||||
|
||||
43
.github/workflows/deploy.yml
vendored
43
.github/workflows/deploy.yml
vendored
@@ -45,8 +45,6 @@ jobs:
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: true
|
||||
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
|
||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: 'affine-web'
|
||||
@@ -61,11 +59,44 @@ jobs:
|
||||
path: ./packages/frontend/web/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-admin:
|
||||
name: Build @affine/admin
|
||||
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/admin --skip-nx-cache
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: 'affine-admin'
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
|
||||
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
|
||||
- name: Upload admin artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: admin
|
||||
path: ./packages/frontend/admin/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-frontend-image:
|
||||
name: Build Frontend Image
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-web
|
||||
- build-admin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download web artifact
|
||||
@@ -73,6 +104,11 @@ jobs:
|
||||
with:
|
||||
name: web
|
||||
path: ./packages/frontend/web/dist
|
||||
- name: Download admin artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: admin
|
||||
path: ./packages/frontend/admin/dist
|
||||
- name: Setup env
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
@@ -94,7 +130,7 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build front Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -137,6 +173,7 @@ jobs:
|
||||
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
|
||||
COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }}
|
||||
COPILOT_UNSPLASH_API_KEY: ${{ secrets.COPILOT_UNSPLASH_API_KEY }}
|
||||
METRICS_CUSTOMER_IO_TOKEN: ${{ secrets.METRICS_CUSTOMER_IO_TOKEN }}
|
||||
MAILER_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
|
||||
MAILER_USER: ${{ secrets.OAUTH_EMAIL_LOGIN }}
|
||||
MAILER_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}
|
||||
|
||||
2
.github/workflows/workers.yml
vendored
2
.github/workflows/workers.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
uses: cloudflare/wrangler-action@v3.5.0
|
||||
uses: cloudflare/wrangler-action@v3.7.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
@@ -12,6 +12,7 @@ storybook-static
|
||||
web-static
|
||||
public
|
||||
packages/backend/server/src/schema.gql
|
||||
packages/backend/server/src/fundamentals/error/errors.gen.ts
|
||||
packages/frontend/i18n/src/i18n-generated.ts
|
||||
packages/frontend/graphql/src/graphql/index.ts
|
||||
tests/affine-legacy/**/static
|
||||
|
||||
39
.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch
Normal file
39
.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch
Normal file
@@ -0,0 +1,39 @@
|
||||
diff --git a/dist/yjs.cjs b/dist/yjs.cjs
|
||||
index d2dc06ae11a6eb44f8c8445d4298c0e89c3e4da2..a30ab04fa9f3b77666939caa88335c68c40f194c 100644
|
||||
--- a/dist/yjs.cjs
|
||||
+++ b/dist/yjs.cjs
|
||||
@@ -414,7 +414,7 @@ const equalDeleteSets = (ds1, ds2) => {
|
||||
*/
|
||||
|
||||
|
||||
-const generateNewClientId = random__namespace.uint32;
|
||||
+const generateNewClientId = random__namespace.uint53;
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocOpts
|
||||
diff --git a/dist/yjs.mjs b/dist/yjs.mjs
|
||||
index 20c9e58c32bcb6bc714200a2561fd1f542c49523..14267e5e36d9781ca3810d5b70ff8c051dac779e 100644
|
||||
--- a/dist/yjs.mjs
|
||||
+++ b/dist/yjs.mjs
|
||||
@@ -378,7 +378,7 @@ const equalDeleteSets = (ds1, ds2) => {
|
||||
*/
|
||||
|
||||
|
||||
-const generateNewClientId = random.uint32;
|
||||
+const generateNewClientId = random.uint53;
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocOpts
|
||||
diff --git a/src/utils/Doc.js b/src/utils/Doc.js
|
||||
index 62643617c86e57c64dd9babdb792fa8888357ec0..4df5048ab12af1ae0f1154da67f06dce1fda7b49 100644
|
||||
--- a/src/utils/Doc.js
|
||||
+++ b/src/utils/Doc.js
|
||||
@@ -20,7 +20,7 @@ import * as map from 'lib0/map'
|
||||
import * as array from 'lib0/array'
|
||||
import * as promise from 'lib0/promise'
|
||||
|
||||
-export const generateNewClientId = random.uint32
|
||||
+export const generateNewClientId = random.uint53
|
||||
|
||||
/**
|
||||
* @typedef {Object} DocOpts
|
||||
File diff suppressed because one or more lines are too long
@@ -12,4 +12,4 @@ npmPublishAccess: public
|
||||
|
||||
npmPublishRegistry: "https://registry.npmjs.org"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.2.2.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.3.1.cjs
|
||||
|
||||
233
Cargo.lock
generated
233
Cargo.lock
generated
@@ -4,9 +4,9 @@ version = 3
|
||||
|
||||
[[package]]
|
||||
name = "addr2line"
|
||||
version = "0.21.0"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
|
||||
checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
|
||||
dependencies = [
|
||||
"gimli",
|
||||
]
|
||||
@@ -106,9 +106,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.83"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3"
|
||||
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
@@ -136,9 +136,9 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.71"
|
||||
version = "0.3.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d"
|
||||
checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
|
||||
dependencies = [
|
||||
"addr2line",
|
||||
"cc",
|
||||
@@ -219,7 +219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata 0.4.6",
|
||||
"regex-automata 0.4.7",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -243,9 +243,9 @@ checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.0.97"
|
||||
version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4"
|
||||
checksum = "c891175c3fb232128f48de6590095e59198bbeb8620c310be349bfc3afd12c7b"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
@@ -314,9 +314,9 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.12"
|
||||
version = "0.5.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95"
|
||||
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -332,9 +332,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.19"
|
||||
version = "0.8.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
|
||||
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
@@ -353,7 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -388,7 +388,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -417,9 +417,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2"
|
||||
checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -491,12 +491,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "finl_unicode"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.0"
|
||||
@@ -505,7 +499,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"spin 0.9.8",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -641,9 +635,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gimli"
|
||||
version = "0.28.1"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
|
||||
checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -784,15 +778,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.11"
|
||||
@@ -849,24 +834,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
dependencies = [
|
||||
"spin 0.5.2",
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.154"
|
||||
version = "0.2.155"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
|
||||
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19"
|
||||
checksum = "e310b3a6b5907f99202fcdb4960ff45b93735d7c7d96b760fcff8db2dc0e103d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.52.5",
|
||||
@@ -880,9 +865,9 @@ checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
|
||||
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.37"
|
||||
version = "0.1.39"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81eb4061c0582dedea1cbc7aff2240300dd6982e0239d1c99e65c1dbf4a30ba7"
|
||||
checksum = "23aa6811d3bd4deb8a84dde645f943476d13b248d818edcf8ce0b2f37f036b44"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -901,9 +886,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
|
||||
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
@@ -957,15 +942,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.7.2"
|
||||
version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "mimalloc"
|
||||
version = "0.1.41"
|
||||
version = "0.1.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f41a2280ded0da56c8cf898babb86e8f10651a34adcfff190ae9a1159c6908d"
|
||||
checksum = "68914350ae34959d83f732418d51e2427a794055d0b9529f48259ac07af65633"
|
||||
dependencies = [
|
||||
"libmimalloc-sys",
|
||||
]
|
||||
@@ -978,9 +963,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
|
||||
checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
|
||||
dependencies = [
|
||||
"adler",
|
||||
]
|
||||
@@ -1008,14 +993,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.0.0-alpha.2"
|
||||
version = "3.0.0-alpha.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d38fbf4cbfd7d2785d153f4dcce374d515d3dabd688504dd9093f8135829d0"
|
||||
checksum = "4ec04344cc540f5897e97c9821ab99e7eb276b4dca6f3e6e441dfa72e5bcde70"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.5.0",
|
||||
"chrono",
|
||||
"ctor",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"once_cell",
|
||||
"serde",
|
||||
@@ -1030,23 +1016,23 @@ checksum = "e1c0f5d67ee408a4685b61f5ab7e58605c8ae3f2b4189f0127d804ff13d5560a"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.0.0-alpha.1"
|
||||
version = "3.0.0-alpha.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c230c813bfd4d6c7aafead3c075b37f0cf7fecb38be8f4cf5cfcee0b2c273ad0"
|
||||
checksum = "1c6240c4ddca592cde608bbfa26e2af397c3596e413a0c65c9bbcb65c2f1e485"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"convert_case",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "2.0.0-alpha.1"
|
||||
version = "2.0.0-alpha.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4370cc24c2e58d0f3393527b282eb00f1158b304248f549e1ec81bd2927db5fe"
|
||||
checksum = "b32dcc50065508fe2f387076c17adbdf10e038d1c080d48b10196813d94ac6a8"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"once_cell",
|
||||
@@ -1054,7 +1040,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"semver",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1165,9 +1151,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.32.2"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
|
||||
checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -1196,9 +1182,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.2"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb"
|
||||
checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
@@ -1212,7 +1198,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.1",
|
||||
"redox_syscall 0.5.2",
|
||||
"smallvec",
|
||||
"windows-targets 0.52.5",
|
||||
]
|
||||
@@ -1285,9 +1271,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.82"
|
||||
version = "1.0.86"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b"
|
||||
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -1358,23 +1344,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e"
|
||||
checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd"
|
||||
dependencies = [
|
||||
"bitflags 2.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.10.4"
|
||||
version = "1.10.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
|
||||
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata 0.4.6",
|
||||
"regex-syntax 0.8.3",
|
||||
"regex-automata 0.4.7",
|
||||
"regex-syntax 0.8.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1388,13 +1374,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea"
|
||||
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax 0.8.3",
|
||||
"regex-syntax 0.8.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1405,9 +1391,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
|
||||
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
@@ -1419,7 +1405,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"spin 0.9.8",
|
||||
"spin",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@@ -1550,29 +1536,29 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.202"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395"
|
||||
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.202"
|
||||
version = "1.0.204"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838"
|
||||
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.117"
|
||||
version = "1.0.120"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3"
|
||||
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"ryu",
|
||||
@@ -1673,12 +1659,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.8"
|
||||
@@ -1700,11 +1680,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlformat"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c"
|
||||
checksum = "f895e3734318cc55f1fe66258926c9b910c124d47520339efecbb6c59cec7c1f"
|
||||
dependencies = [
|
||||
"itertools",
|
||||
"nom",
|
||||
"unicode_categories",
|
||||
]
|
||||
@@ -1912,20 +1891,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6"
|
||||
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||
dependencies = [
|
||||
"finl_unicode",
|
||||
"unicode-bidi",
|
||||
"unicode-normalization",
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
|
||||
checksum = "0d0208408ba0c3df17ed26eb06992cb1a1268d41b2c0e12e65203fbe3972cee5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
@@ -1940,9 +1919,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.63"
|
||||
version = "2.0.68"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704"
|
||||
checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1969,22 +1948,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.60"
|
||||
version = "1.0.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18"
|
||||
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.60"
|
||||
version = "1.0.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524"
|
||||
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2029,9 +2008,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
|
||||
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bytes",
|
||||
@@ -2048,13 +2027,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.2.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
|
||||
checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2088,7 +2067,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2157,6 +2136,12 @@ dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4259d9d4425d9f0661581b804cb85fe66a4c631cadd8f490d1c13a35d5d9291"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.11.0"
|
||||
@@ -2177,9 +2162,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.0"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
|
||||
checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
@@ -2194,9 +2179,9 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
|
||||
checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"rand",
|
||||
@@ -2264,7 +2249,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -2286,7 +2271,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
"wasm-bindgen-backend",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
@@ -2375,9 +2360,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "749f0da9cc72d82e600d8d2e44cadd0b9eedb9038f71a1c58556ac1c5791813b"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.5",
|
||||
]
|
||||
@@ -2571,11 +2556,11 @@ checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.63",
|
||||
"syn 2.0.68",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.7.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
|
||||
@@ -185,7 +185,7 @@ See [LICENSE] for details.
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.77.2-dea584
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.79.0-dea584
|
||||
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
|
||||
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
|
||||
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
|
||||
|
||||
@@ -6,8 +6,8 @@ We recommend users to always use the latest major version. Security updates will
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.13.x (stable) | :white_check_mark: |
|
||||
| < 0.13.x | :x: |
|
||||
| 0.15.x (stable) | :white_check_mark: |
|
||||
| < 0.15.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0",
|
||||
"serve": "^14.2.1",
|
||||
"typedoc": "^0.25.13"
|
||||
"typedoc": "^0.26.0"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
"version": "0.15.0"
|
||||
}
|
||||
|
||||
24
package.json
24
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -59,10 +59,10 @@
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@magic-works/i18n-codegen": "^0.6.0",
|
||||
"@nx/vite": "19.0.4",
|
||||
"@playwright/test": "^1.44.0",
|
||||
"@nx/vite": "19.4.3",
|
||||
"@playwright/test": "=1.44.1",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@types/affine__env": "workspace:*",
|
||||
"@types/eslint": "^8.56.7",
|
||||
@@ -75,7 +75,7 @@
|
||||
"@vitest/coverage-istanbul": "1.6.0",
|
||||
"@vitest/ui": "1.6.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^30.0.0",
|
||||
"electron": "~30.2.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import-x": "^0.5.0",
|
||||
@@ -87,29 +87,30 @@
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"eslint-plugin-vue": "^9.24.1",
|
||||
"fake-indexeddb": "5.0.2",
|
||||
"fake-indexeddb": "6.0.0",
|
||||
"happy-dom": "^14.7.1",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"msw": "^2.3.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"nx": "^19.0.0",
|
||||
"nyc": "^15.1.0",
|
||||
"oxlint": "0.3.5",
|
||||
"nyc": "^17.0.0",
|
||||
"oxlint": "0.6.1",
|
||||
"prettier": "^3.2.5",
|
||||
"semver": "^7.6.0",
|
||||
"serve": "^14.2.1",
|
||||
"string-width": "^7.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-istanbul": "^6.0.0",
|
||||
"vite-plugin-static-copy": "^1.0.2",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"packageManager": "yarn@4.2.2",
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"resolutions": {
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
|
||||
"array-includes": "npm:@nolyfill/array-includes@latest",
|
||||
@@ -168,7 +169,6 @@
|
||||
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
|
||||
"@reforged/maker-appimage/@electron-forge/maker-base": "7.4.0",
|
||||
"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"
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest"
|
||||
}
|
||||
}
|
||||
|
||||
12
packages/backend/native/index.d.ts
vendored
12
packages/backend/native/index.d.ts
vendored
@@ -1,20 +1,20 @@
|
||||
/* auto-generated by NAPI-RS */
|
||||
/* eslint-disable */
|
||||
export class Tokenizer {
|
||||
export declare class Tokenizer {
|
||||
count(content: string, allowedSpecial?: Array<string> | undefined | null): number
|
||||
}
|
||||
|
||||
export function fromModelName(modelName: string): Tokenizer | null
|
||||
export declare function fromModelName(modelName: string): Tokenizer | null
|
||||
|
||||
export function getMime(input: Uint8Array): string
|
||||
export declare function getMime(input: Uint8Array): string
|
||||
|
||||
/**
|
||||
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||
* result binary.
|
||||
*/
|
||||
export function mergeUpdatesInApplyWay(updates: Array<Buffer>): Buffer
|
||||
export declare function mergeUpdatesInApplyWay(updates: Array<Buffer>): Buffer
|
||||
|
||||
export function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/server-native",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
@@ -33,12 +33,12 @@
|
||||
"build:debug": "napi build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "3.0.0-alpha.55",
|
||||
"@napi-rs/cli": "3.0.0-alpha.60",
|
||||
"lib0": "^0.2.93",
|
||||
"nx": "^19.0.0",
|
||||
"nx-cloud": "^19.0.0",
|
||||
"tiktoken": "^1.0.15",
|
||||
"tinybench": "^2.8.0",
|
||||
"yjs": "^13.6.14"
|
||||
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "stripe_subscription_id" DROP NOT NULL,
|
||||
ALTER COLUMN "end" DROP NOT NULL;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RuntimeConfigType" AS ENUM ('String', 'Number', 'Boolean', 'Object', 'Array');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "app_runtime_settings" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"type" "RuntimeConfigType" NOT NULL,
|
||||
"module" VARCHAR NOT NULL,
|
||||
"key" VARCHAR NOT NULL,
|
||||
"value" JSON NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(6),
|
||||
"last_updated_by" VARCHAR(36),
|
||||
|
||||
CONSTRAINT "app_runtime_settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "app_runtime_settings_module_key_key" ON "app_runtime_settings"("module", "key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "app_runtime_settings" ADD CONSTRAINT "app_runtime_settings_last_updated_by_fkey" FOREIGN KEY ("last_updated_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "deleted_at" TIMESTAMPTZ(6),
|
||||
ADD COLUMN "messageCost" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "tokenCost" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `model` on table `ai_prompts_metadata` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "model" SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "parent_session_id" VARCHAR(36);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ADD COLUMN "config" JSON;
|
||||
@@ -0,0 +1,146 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "_data_migrations" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "started_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "finished_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_messages" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "session_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "doc_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "parent_session_id" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_runtime_settings" ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "last_updated_by" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "multiple_users_sessions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshot_histories"
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "guid" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "timestamp" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "snapshots" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_connected_accounts" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_features" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_sessions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "session_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_stripe_customers" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_subscriptions" ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "start" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "end" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "next_bill_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "canceled_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "trial_start" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "trial_end" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "name" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "email" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "verification_tokens" ALTER COLUMN "token" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_features" ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3),
|
||||
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_page_user_permissions"
|
||||
ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "page_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_pages" ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "page_id" SET DATA TYPE VARCHAR;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspace_user_permissions" ALTER COLUMN "id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "workspace_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "user_id" SET DATA TYPE VARCHAR,
|
||||
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMP(3);
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "accounts";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "blobs";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "new_features_waiting_list";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "optimized_blobs";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "sessions";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "user_workspace_permissions";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "verificationtokens";
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
provider = "postgresql"
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -15,12 +15,14 @@
|
||||
"test:coverage": "c8 ava --concurrency 1 --serial",
|
||||
"postinstall": "prisma generate",
|
||||
"data-migration": "node --loader ts-node/esm/transpile-only.mjs ./src/data/index.ts",
|
||||
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run"
|
||||
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run",
|
||||
"predeploy:ts": "yarn prisma migrate deploy && node --loader ts-node/esm/transpile-only.mjs ./src/data/index.ts run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.10.2",
|
||||
"@aws-sdk/client-s3": "^3.552.0",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.18.0",
|
||||
"@fal-ai/serverless-client": "^0.13.0",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.19.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.2.0",
|
||||
"@google-cloud/opentelemetry-resource-util": "^2.2.0",
|
||||
"@keyv/redis": "^2.8.4",
|
||||
@@ -33,39 +35,41 @@
|
||||
"@nestjs/platform-socket.io": "^10.3.7",
|
||||
"@nestjs/schedule": "^4.0.1",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/throttler": "5.1.2",
|
||||
"@nestjs/throttler": "5.2.0",
|
||||
"@nestjs/websockets": "^10.3.7",
|
||||
"@node-rs/argon2": "^1.8.0",
|
||||
"@node-rs/crc32": "^1.10.0",
|
||||
"@node-rs/jsonwebtoken": "^0.5.2",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/core": "^1.24.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.51.1",
|
||||
"@opentelemetry/exporter-zipkin": "^1.24.1",
|
||||
"@opentelemetry/host-metrics": "^0.35.1",
|
||||
"@opentelemetry/instrumentation": "^0.51.1",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.40.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.51.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.40.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.37.1",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.39.0",
|
||||
"@opentelemetry/resources": "^1.24.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.24.1",
|
||||
"@opentelemetry/sdk-node": "^0.51.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.24.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.24.1",
|
||||
"@prisma/client": "^5.12.1",
|
||||
"@prisma/instrumentation": "^5.12.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^1.25.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.52.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.25.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.2",
|
||||
"@opentelemetry/instrumentation": "^0.52.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.52.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.42.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.39.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.41.0",
|
||||
"@opentelemetry/resources": "^1.25.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.25.0",
|
||||
"@opentelemetry/sdk-node": "^0.52.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.25.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.25.0",
|
||||
"@prisma/client": "^5.15.0",
|
||||
"@prisma/instrumentation": "^5.15.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv-cli": "^7.4.1",
|
||||
"express": "^4.19.2",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
"graphql-upload": "^16.0.2",
|
||||
"html-validate": "^8.20.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"keyv": "^4.5.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -78,18 +82,20 @@
|
||||
"on-headers": "^1.0.2",
|
||||
"openai": "^4.33.0",
|
||||
"parse-duration": "^1.1.0",
|
||||
"piscina": "^4.5.1",
|
||||
"pretty-time": "^1.1.0",
|
||||
"prisma": "^5.12.1",
|
||||
"prom-client": "^15.1.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.6.0",
|
||||
"ses": "^1.4.1",
|
||||
"socket.io": "^4.7.5",
|
||||
"stripe": "^15.0.0",
|
||||
"stripe": "^16.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"ws": "^8.16.0",
|
||||
"yjs": "^13.6.14",
|
||||
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -113,7 +119,7 @@
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.10",
|
||||
"ava": "^6.1.2",
|
||||
"c8": "^9.1.0",
|
||||
"c8": "^10.0.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"sinon": "^18.0.0",
|
||||
"supertest": "^7.0.0"
|
||||
@@ -130,7 +136,13 @@
|
||||
"ts-node/esm/transpile-only.mjs",
|
||||
"--es-module-specifier-resolution=node"
|
||||
],
|
||||
"watchMode": {
|
||||
"ignoreChanges": [
|
||||
"**/*.gen.*"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"**/__tests__/**/*.spec.ts",
|
||||
"tests/**/*.spec.ts",
|
||||
"tests/**/*.e2e.ts"
|
||||
],
|
||||
@@ -159,9 +171,11 @@
|
||||
],
|
||||
"ignore": [
|
||||
"**/__tests__/**",
|
||||
"**/dist/**"
|
||||
"**/dist/**",
|
||||
"*.gen.*"
|
||||
],
|
||||
"env": {
|
||||
"AFFINE_SERVER_EXTERNAL_URL": "http://localhost:8080",
|
||||
"TS_NODE_TRANSPILE_ONLY": true,
|
||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||
"DEBUG": "affine:*",
|
||||
@@ -179,7 +193,8 @@
|
||||
"exclude": [
|
||||
"scripts",
|
||||
"node_modules",
|
||||
"**/*.spec.ts"
|
||||
"**/*.spec.ts",
|
||||
"**/*.e2e.ts"
|
||||
]
|
||||
},
|
||||
"stableVersion": "0.5.3",
|
||||
|
||||
@@ -11,42 +11,43 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String
|
||||
email String @unique
|
||||
emailVerifiedAt DateTime? @map("email_verified")
|
||||
name String @db.VarChar
|
||||
email String @unique @db.VarChar
|
||||
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamp(3)
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
/// Not available if user signed up through OAuth providers
|
||||
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)
|
||||
|
||||
features UserFeatures[]
|
||||
customer UserStripeCustomer?
|
||||
subscriptions UserSubscription[]
|
||||
invoices UserInvoice[]
|
||||
workspacePermissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
features UserFeature[]
|
||||
customer UserStripeCustomer?
|
||||
subscriptions UserSubscription[]
|
||||
invoices UserInvoice[]
|
||||
workspacePermissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
aiSessions AiSession[]
|
||||
updatedRuntimeConfigs RuntimeConfig[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
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)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -56,9 +57,9 @@ model ConnectedAccount {
|
||||
}
|
||||
|
||||
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)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
userSessions UserSession[]
|
||||
|
||||
@@ -66,11 +67,11 @@ model Session {
|
||||
}
|
||||
|
||||
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)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
sessionId String @map("session_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -80,10 +81,10 @@ model UserSession {
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @db.VarChar(36)
|
||||
token String @db.VarChar
|
||||
type Int @db.SmallInt
|
||||
credential String? @db.Text
|
||||
expiresAt DateTime @db.Timestamptz(6)
|
||||
expiresAt DateTime @db.Timestamp(3)
|
||||
|
||||
@@unique([type, token])
|
||||
@@map("verification_tokens")
|
||||
@@ -92,12 +93,12 @@ model VerificationToken {
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
pages WorkspacePage[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
features WorkspaceFeatures[]
|
||||
features WorkspaceFeature[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
@@ -108,8 +109,8 @@ model Workspace {
|
||||
// Only the ones that have ever changed will have records here,
|
||||
// and for others we will make sure it's has a default value return in our bussiness logic.
|
||||
model WorkspacePage {
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
pageId String @map("page_id") @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
pageId String @map("page_id") @db.VarChar
|
||||
public Boolean @default(false)
|
||||
// Page/Edgeless
|
||||
mode Int @default(0) @db.SmallInt
|
||||
@@ -120,31 +121,15 @@ model WorkspacePage {
|
||||
@@map("workspace_pages")
|
||||
}
|
||||
|
||||
// @deprecated, use WorkspaceUserPermission
|
||||
model DeprecatedUserWorkspacePermission {
|
||||
model WorkspaceUserPermission {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
subPageId String? @map("sub_page_id") @db.VarChar
|
||||
userId String? @map("entity_id") @db.VarChar
|
||||
/// Read/Write/Admin/Owner
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, subPageId, userId])
|
||||
@@map("user_workspace_permissions")
|
||||
}
|
||||
|
||||
model WorkspaceUserPermission {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar
|
||||
// Read/Write
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -154,15 +139,15 @@ model WorkspaceUserPermission {
|
||||
}
|
||||
|
||||
model WorkspacePageUserPermission {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
pageId String @map("page_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
pageId String @map("page_id") @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
// Read/Write
|
||||
type Int @db.SmallInt
|
||||
/// Whether the permission invitation is accepted by the user
|
||||
accepted Boolean @default(false)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
@@ -175,9 +160,9 @@ model WorkspacePageUserPermission {
|
||||
// for example:
|
||||
// - early access is a feature that allow some users to access the insider version
|
||||
// - pro plan is a quota that allow some users access to more resources after they pay
|
||||
model UserFeatures {
|
||||
model UserFeature {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar
|
||||
featureId Int @map("feature_id") @db.Integer
|
||||
|
||||
// we will record the reason why the feature is enabled/disabled
|
||||
@@ -185,16 +170,16 @@ model UserFeatures {
|
||||
// - pro_plan_v1: "user buy the pro plan"
|
||||
reason String @db.VarChar
|
||||
// record the quota enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(6)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("user_features")
|
||||
@@ -203,9 +188,9 @@ model UserFeatures {
|
||||
// feature gates is a way to enable/disable features for a workspace
|
||||
// for example:
|
||||
// - copilet is a feature that allow some users in a workspace to access the copilet feature
|
||||
model WorkspaceFeatures {
|
||||
model WorkspaceFeature {
|
||||
id Int @id @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
featureId Int @map("feature_id") @db.Integer
|
||||
|
||||
// we will record the reason why the feature is enabled/disabled
|
||||
@@ -213,21 +198,21 @@ model WorkspaceFeatures {
|
||||
// - copilet_v1: "owner buy the copilet feature package"
|
||||
reason String @db.VarChar
|
||||
// record the feature enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(6)
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
feature Feature @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("workspace_features")
|
||||
}
|
||||
|
||||
model Features {
|
||||
model Feature {
|
||||
id Int @id @default(autoincrement())
|
||||
feature String @db.VarChar
|
||||
version Int @default(0) @db.Integer
|
||||
@@ -235,82 +220,15 @@ model Features {
|
||||
type Int @db.Integer
|
||||
// configs, define by feature conntroller
|
||||
configs Json @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
UserFeatureGates UserFeatures[]
|
||||
WorkspaceFeatures WorkspaceFeatures[]
|
||||
UserFeatureGates UserFeature[]
|
||||
WorkspaceFeatures WorkspaceFeature[]
|
||||
|
||||
@@unique([feature, version])
|
||||
@@map("features")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String @map("provider_account_id")
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthSession {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthVerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verificationtokens")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model Blob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
blob Bytes @db.ByteA
|
||||
length BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// not for keeping, but for snapshot history
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, hash])
|
||||
@@map("blobs")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model OptimizedBlob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
params String @db.VarChar
|
||||
blob Bytes @db.ByteA
|
||||
length BigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// not for keeping, but for snapshot history
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, hash, params])
|
||||
@@map("optimized_blobs")
|
||||
}
|
||||
|
||||
// the latest snapshot of each doc that we've seen
|
||||
// Snapshot + Updates are the latest state of the doc
|
||||
model Snapshot {
|
||||
@@ -319,10 +237,10 @@ model Snapshot {
|
||||
blob Bytes @db.ByteA
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
// the `updated_at` field will not record the time of record changed,
|
||||
// but the created time of last seen update that has been merged into snapshot.
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
@@id([id, workspaceId])
|
||||
@@map("snapshots")
|
||||
@@ -333,37 +251,28 @@ model Update {
|
||||
id String @map("guid") @db.VarChar
|
||||
seq Int @db.Integer
|
||||
blob Bytes @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
@@id([workspaceId, id, seq])
|
||||
@@map("updates")
|
||||
}
|
||||
|
||||
model SnapshotHistory {
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
id String @map("guid") @db.VarChar(36)
|
||||
timestamp DateTime @db.Timestamptz(6)
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
id String @map("guid") @db.VarChar
|
||||
timestamp DateTime @db.Timestamp(3)
|
||||
blob Bytes @db.ByteA
|
||||
state Bytes? @db.ByteA
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamptz(6)
|
||||
expiredAt DateTime @map("expired_at") @db.Timestamp(3)
|
||||
|
||||
@@id([workspaceId, id, timestamp])
|
||||
@@map("snapshot_histories")
|
||||
}
|
||||
|
||||
model NewFeaturesWaitingList {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
email String @unique
|
||||
type Int @db.SmallInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("new_features_waiting_list")
|
||||
}
|
||||
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -372,30 +281,30 @@ model UserStripeCustomer {
|
||||
|
||||
model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
recurring String @db.VarChar(20)
|
||||
// subscription.id
|
||||
stripeSubscriptionId String @unique @map("stripe_subscription_id")
|
||||
// subscription.id, null for linefetime payment
|
||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamptz(6)
|
||||
// subscription.current_period_end
|
||||
end DateTime @map("end") @db.Timestamptz(6)
|
||||
start DateTime @map("start") @db.Timestamp(3)
|
||||
// subscription.current_period_end, null for lifetime payment
|
||||
end DateTime? @map("end") @db.Timestamp(3)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamp(3)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6)
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamp(3)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(6)
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamp(3)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(6)
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamp(3)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, plan])
|
||||
@@ -404,7 +313,7 @@ model UserSubscription {
|
||||
|
||||
model UserInvoice {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar
|
||||
stripeInvoiceId String @unique @map("stripe_invoice_id")
|
||||
currency String @db.VarChar(3)
|
||||
// CNY 12.50 stored as 1250
|
||||
@@ -412,8 +321,8 @@ model UserInvoice {
|
||||
status String @db.VarChar(20)
|
||||
plan String @db.VarChar(20)
|
||||
recurring String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
// billing reason
|
||||
reason String @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
@@ -441,7 +350,7 @@ model AiPromptMessage {
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
prompt AiPrompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -455,8 +364,9 @@ model AiPrompt {
|
||||
// an mark identifying which view to use to display the session
|
||||
// it is only used in the frontend and does not affect the backend
|
||||
action String? @db.VarChar
|
||||
model String? @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
model String @db.VarChar
|
||||
config Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
messages AiPromptMessage[]
|
||||
sessions AiSession[]
|
||||
@@ -465,14 +375,14 @@ model AiPrompt {
|
||||
}
|
||||
|
||||
model AiSessionMessage {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
sessionId String @map("session_id") @db.VarChar(36)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
sessionId String @map("session_id") @db.VarChar
|
||||
role AiPromptRole
|
||||
content String @db.Text
|
||||
attachments Json? @db.Json
|
||||
params Json? @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
|
||||
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -480,12 +390,17 @@ model AiSessionMessage {
|
||||
}
|
||||
|
||||
model AiSession {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
docId String @map("doc_id") @db.VarChar(36)
|
||||
promptName String @map("prompt_name") @db.VarChar(32)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
docId String @map("doc_id") @db.VarChar
|
||||
promptName String @map("prompt_name") @db.VarChar(32)
|
||||
// the session id of the parent session if this session is a forked session
|
||||
parentSessionId String? @map("parent_session_id") @db.VarChar
|
||||
messageCost Int @default(0)
|
||||
tokenCost Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
|
||||
@@ -495,10 +410,35 @@ model AiSession {
|
||||
}
|
||||
|
||||
model DataMigration {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamp(3)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamp(3)
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
enum RuntimeConfigType {
|
||||
String
|
||||
Number
|
||||
Boolean
|
||||
Object
|
||||
Array
|
||||
}
|
||||
|
||||
model RuntimeConfig {
|
||||
id String @id @db.VarChar
|
||||
type RuntimeConfigType
|
||||
module String @db.VarChar
|
||||
key String @db.VarChar
|
||||
value Json @db.Json
|
||||
description String @db.Text
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
|
||||
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
|
||||
|
||||
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])
|
||||
|
||||
@@unique([module, key])
|
||||
@@map("app_runtime_settings")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import {
|
||||
DynamicModule,
|
||||
ForwardReference,
|
||||
Logger,
|
||||
Module,
|
||||
} from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { get } from 'lodash-es';
|
||||
@@ -17,8 +22,12 @@ import { UserModule } from './core/user';
|
||||
import { WorkspaceModule } from './core/workspaces';
|
||||
import { getOptionalModuleMetadata } from './fundamentals';
|
||||
import { CacheModule } from './fundamentals/cache';
|
||||
import type { AvailablePlugins } from './fundamentals/config';
|
||||
import { Config, ConfigModule } from './fundamentals/config';
|
||||
import {
|
||||
AFFiNEConfig,
|
||||
ConfigModule,
|
||||
mergeConfigOverride,
|
||||
} from './fundamentals/config';
|
||||
import { ErrorModule } from './fundamentals/error';
|
||||
import { EventModule } from './fundamentals/event';
|
||||
import { GqlModule } from './fundamentals/graphql';
|
||||
import { HelpersModule } from './fundamentals/helpers';
|
||||
@@ -30,6 +39,7 @@ import { StorageProviderModule } from './fundamentals/storage';
|
||||
import { RateLimiterModule } from './fundamentals/throttler';
|
||||
import { WebSocketModule } from './fundamentals/websocket';
|
||||
import { REGISTERED_PLUGINS } from './plugins';
|
||||
import { ENABLED_PLUGINS } from './plugins/registry';
|
||||
|
||||
export const FunctionalityModules = [
|
||||
ConfigModule.forRoot(),
|
||||
@@ -43,54 +53,77 @@ export const FunctionalityModules = [
|
||||
MailModule,
|
||||
StorageProviderModule,
|
||||
HelpersModule,
|
||||
ErrorModule,
|
||||
];
|
||||
|
||||
function filterOptionalModule(
|
||||
config: AFFiNEConfig,
|
||||
module: AFFiNEModule | Promise<DynamicModule> | ForwardReference<any>
|
||||
) {
|
||||
// can't deal with promise or forward reference
|
||||
if (module instanceof Promise || 'forwardRef' in module) {
|
||||
return module;
|
||||
}
|
||||
|
||||
const requirements = getOptionalModuleMetadata(module, 'requires');
|
||||
// if condition not set or condition met, include the module
|
||||
if (requirements?.length) {
|
||||
const nonMetRequirements = requirements.filter(c => {
|
||||
const value = get(config, c);
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim().length === 0)
|
||||
);
|
||||
});
|
||||
|
||||
if (nonMetRequirements.length) {
|
||||
const name = 'module' in module ? module.module.name : module.name;
|
||||
new Logger(name).warn(
|
||||
`${name} is not enabled because of the required configuration is not satisfied.`,
|
||||
'Unsatisfied configuration:',
|
||||
...nonMetRequirements.map(config => ` AFFiNE.${config}`)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const predicator = getOptionalModuleMetadata(module, 'if');
|
||||
if (predicator && !predicator(config)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contribution = getOptionalModuleMetadata(module, 'contributesTo');
|
||||
if (contribution) {
|
||||
ADD_ENABLED_FEATURES(contribution);
|
||||
}
|
||||
|
||||
const subModules = getOptionalModuleMetadata(module, 'imports');
|
||||
const filteredSubModules = subModules
|
||||
?.map(subModule => filterOptionalModule(config, subModule))
|
||||
.filter(Boolean);
|
||||
Reflect.defineMetadata('imports', filteredSubModules, module);
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
export class AppModuleBuilder {
|
||||
private readonly modules: AFFiNEModule[] = [];
|
||||
constructor(private readonly config: Config) {}
|
||||
constructor(private readonly config: AFFiNEConfig) {}
|
||||
|
||||
use(...modules: AFFiNEModule[]): this {
|
||||
modules.forEach(m => {
|
||||
const requirements = getOptionalModuleMetadata(m, 'requires');
|
||||
// if condition not set or condition met, include the module
|
||||
if (requirements?.length) {
|
||||
const nonMetRequirements = requirements.filter(c => {
|
||||
const value = get(this.config, c);
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim().length === 0)
|
||||
);
|
||||
});
|
||||
|
||||
if (nonMetRequirements.length) {
|
||||
const name = 'module' in m ? m.module.name : m.name;
|
||||
new Logger(name).warn(
|
||||
`${name} is not enabled because of the required configuration is not satisfied.`,
|
||||
'Unsatisfied configuration:',
|
||||
...nonMetRequirements.map(config => ` AFFiNE.${config}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result = filterOptionalModule(this.config, m);
|
||||
if (result) {
|
||||
this.modules.push(m);
|
||||
}
|
||||
|
||||
const predicator = getOptionalModuleMetadata(m, 'if');
|
||||
if (predicator && !predicator(this.config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contribution = getOptionalModuleMetadata(m, 'contributesTo');
|
||||
if (contribution) {
|
||||
ADD_ENABLED_FEATURES(contribution);
|
||||
}
|
||||
this.modules.push(m);
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
useIf(
|
||||
predicator: (config: Config) => boolean,
|
||||
predicator: (config: AFFiNEConfig) => boolean,
|
||||
...modules: AFFiNEModule[]
|
||||
): this {
|
||||
if (predicator(this.config)) {
|
||||
@@ -112,6 +145,7 @@ export class AppModuleBuilder {
|
||||
}
|
||||
|
||||
function buildAppModule() {
|
||||
AFFiNE = mergeConfigOverride(AFFiNE);
|
||||
const factor = new AppModuleBuilder(AFFiNE);
|
||||
|
||||
factor
|
||||
@@ -143,12 +177,20 @@ function buildAppModule() {
|
||||
config => config.isSelfhosted,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join('/app', 'static'),
|
||||
exclude: ['/admin*'],
|
||||
})
|
||||
)
|
||||
.useIf(
|
||||
config => config.isSelfhosted,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join('/app', 'static', 'admin'),
|
||||
serveRoot: '/admin',
|
||||
})
|
||||
);
|
||||
|
||||
// plugin modules
|
||||
AFFiNE.plugins.enabled.forEach(name => {
|
||||
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
|
||||
ENABLED_PLUGINS.forEach(name => {
|
||||
const plugin = REGISTERED_PLUGINS.get(name);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin ${name}`);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function createApp() {
|
||||
|
||||
app.use(
|
||||
graphqlUploadExpress({
|
||||
// TODO: dynamic limit by quota
|
||||
// TODO(@darkskygit): dynamic limit by quota maybe?
|
||||
maxFileSize: 100 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
})
|
||||
@@ -50,11 +50,13 @@ export async function createApp() {
|
||||
app.useWebSocketAdapter(adapter);
|
||||
}
|
||||
|
||||
if (AFFiNE.isSelfhosted && AFFiNE.telemetry.enabled) {
|
||||
if (AFFiNE.isSelfhosted && AFFiNE.metrics.telemetry.enabled) {
|
||||
const mixpanel = await import('mixpanel');
|
||||
mixpanel.init(AFFiNE.telemetry.token).track('selfhost-server-started', {
|
||||
version: AFFiNE.version,
|
||||
});
|
||||
mixpanel
|
||||
.init(AFFiNE.metrics.telemetry.token)
|
||||
.track('selfhost-server-started', {
|
||||
version: AFFiNE.version,
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
// Convenient way to map environment variables to config values.
|
||||
AFFiNE.ENV_MAP = {
|
||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
||||
AFFINE_SERVER_HOST: 'host',
|
||||
AFFINE_SERVER_SUB_PATH: 'path',
|
||||
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
|
||||
DATABASE_URL: 'db.url',
|
||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||
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',
|
||||
AFFINE_SERVER_EXTERNAL_URL: ['server.externalUrl'],
|
||||
AFFINE_SERVER_PORT: ['server.port', 'int'],
|
||||
AFFINE_SERVER_HOST: 'server.host',
|
||||
AFFINE_SERVER_SUB_PATH: 'server.path',
|
||||
AFFINE_SERVER_HTTPS: ['server.https', 'boolean'],
|
||||
ENABLE_TELEMETRY: ['metrics.telemetry.enabled', 'boolean'],
|
||||
MAILER_HOST: 'mailer.host',
|
||||
MAILER_PORT: ['mailer.port', 'int'],
|
||||
MAILER_USER: 'mailer.auth.user',
|
||||
MAILER_PASSWORD: 'mailer.auth.pass',
|
||||
MAILER_SENDER: 'mailer.from.address',
|
||||
MAILER_SECURE: ['mailer.secure', 'boolean'],
|
||||
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
|
||||
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
|
||||
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',
|
||||
OAUTH_OIDC_ISSUER: 'plugins.oauth.providers.oidc.issuer',
|
||||
OAUTH_OIDC_CLIENT_ID: 'plugins.oauth.providers.oidc.clientId',
|
||||
OAUTH_OIDC_CLIENT_SECRET: 'plugins.oauth.providers.oidc.clientSecret',
|
||||
OAUTH_OIDC_SCOPE: 'plugins.oauth.providers.oidc.args.scope',
|
||||
OAUTH_OIDC_CLAIM_MAP_USERNAME: 'plugins.oauth.providers.oidc.args.claim_id',
|
||||
OAUTH_OIDC_CLAIM_MAP_EMAIL: 'plugins.oauth.providers.oidc.args.claim_email',
|
||||
OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name',
|
||||
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'],
|
||||
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
|
||||
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
|
||||
COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey',
|
||||
@@ -28,16 +33,6 @@ AFFiNE.ENV_MAP = {
|
||||
REDIS_SERVER_PASSWORD: 'plugins.redis.password',
|
||||
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
||||
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
|
||||
DOC_MERGE_USE_JWST_CODEC: [
|
||||
'doc.manager.experimentalMergeWithYOcto',
|
||||
'boolean',
|
||||
],
|
||||
STRIPE_API_KEY: 'plugins.payment.stripe.keys.APIKey',
|
||||
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
|
||||
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
|
||||
FEATURES_SYNC_CLIENT_VERSION_CHECK: [
|
||||
'featureFlags.syncClientVersionCheck',
|
||||
'boolean',
|
||||
],
|
||||
TELEMETRY_ENABLE: ['telemetry.enabled', 'boolean'],
|
||||
};
|
||||
|
||||
@@ -20,35 +20,47 @@ const env = process.env;
|
||||
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
||||
|
||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
AFFiNE.plugins.use('cloudflare-r2', {
|
||||
AFFiNE.use('cloudflare-r2', {
|
||||
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
AFFiNE.storage.storages.avatar.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
||||
AFFiNE.storages.avatar.provider = 'cloudflare-r2';
|
||||
AFFiNE.storages.avatar.bucket = 'account-avatar';
|
||||
AFFiNE.storages.avatar.publicLinkFactory = key =>
|
||||
`https://avatar.affineassets.com/${key}`;
|
||||
|
||||
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
||||
AFFiNE.storages.blob.provider = 'cloudflare-r2';
|
||||
AFFiNE.storages.blob.bucket = `workspace-blobs-${
|
||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||
}`;
|
||||
|
||||
AFFiNE.storage.storages.copilot.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.copilot.bucket = `workspace-copilot-${
|
||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||
}`;
|
||||
AFFiNE.use('copilot', {
|
||||
storage: {
|
||||
provider: 'cloudflare-r2',
|
||||
bucket: `workspace-copilot-${AFFiNE.affine.canary ? 'canary' : 'prod'}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
AFFiNE.plugins.use('copilot', {
|
||||
openai: {},
|
||||
fal: {},
|
||||
AFFiNE.use('copilot', {
|
||||
openai: {
|
||||
apiKey: '',
|
||||
},
|
||||
fal: {
|
||||
apiKey: '',
|
||||
},
|
||||
});
|
||||
AFFiNE.plugins.use('redis');
|
||||
AFFiNE.plugins.use('payment', {
|
||||
AFFiNE.use('redis', {
|
||||
host: env.REDIS_SERVER_HOST,
|
||||
db: 0,
|
||||
port: 6379,
|
||||
username: env.REDIS_SERVER_USER,
|
||||
password: env.REDIS_SERVER_PASSWORD,
|
||||
});
|
||||
AFFiNE.use('payment', {
|
||||
stripe: {
|
||||
keys: {
|
||||
// fake the key to ensure the server generate full GraphQL Schema even env vars are not set
|
||||
@@ -57,7 +69,7 @@ AFFiNE.plugins.use('payment', {
|
||||
},
|
||||
},
|
||||
});
|
||||
AFFiNE.plugins.use('oauth');
|
||||
AFFiNE.use('oauth');
|
||||
|
||||
if (AFFiNE.deploy) {
|
||||
AFFiNE.mailer = {
|
||||
@@ -68,5 +80,5 @@ if (AFFiNE.deploy) {
|
||||
},
|
||||
};
|
||||
|
||||
AFFiNE.plugins.use('gcloud');
|
||||
AFFiNE.use('gcloud');
|
||||
}
|
||||
|
||||
@@ -26,22 +26,17 @@
|
||||
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
|
||||
//
|
||||
// /* Whether the server is deployed behind a HTTPS proxied environment */
|
||||
AFFiNE.https = false;
|
||||
AFFiNE.server.https = false;
|
||||
// /* Domain of your server that your server will be available at */
|
||||
AFFiNE.host = 'localhost';
|
||||
AFFiNE.server.host = 'localhost';
|
||||
// /* The local port of your server that will listen on */
|
||||
AFFiNE.port = 3010;
|
||||
AFFiNE.server.port = 3010;
|
||||
// /* The sub path of your server */
|
||||
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
||||
// AFFiNE.path = '/affine';
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
// ## Database settings ##
|
||||
// ###############################################################
|
||||
//
|
||||
// /* The URL of the database where most of AFFiNE server data will be stored in */
|
||||
// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine';
|
||||
// /* For example, if you set `AFFiNE.server.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
||||
// AFFiNE.server.path = '/affine';
|
||||
// /* The external URL of your server, will be consist of protocol + host + port by default */
|
||||
// /* Useful when you want to customize the link to server resources for example the doc share link or email link */
|
||||
// AFFiNE.server.externalUrl = 'http://affine.local:8080'
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
@@ -52,19 +47,12 @@ AFFiNE.port = 3010;
|
||||
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
|
||||
// AFFiNE.metrics.enabled = true;
|
||||
//
|
||||
// /* Authentication Settings */
|
||||
// /* Whether allow anyone signup */
|
||||
// AFFiNE.auth.allowSignup = true;
|
||||
//
|
||||
// /* User Signup password limitation */
|
||||
// AFFiNE.auth.password = {
|
||||
// minLength: 8,
|
||||
// maxLength: 32,
|
||||
// };
|
||||
//
|
||||
// /* How long the login session would last by default */
|
||||
// AFFiNE.auth.session = {
|
||||
// /* How long the login session would last by default */
|
||||
// ttl: 15 * 24 * 60 * 60, // 15 days
|
||||
// /* How long we should refresh the token before it getting expired */
|
||||
// ttr: 7 * 24 * 60 * 60, // 7 days
|
||||
// };
|
||||
//
|
||||
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
|
||||
@@ -85,9 +73,6 @@ AFFiNE.port = 3010;
|
||||
// /* How long the buffer time of creating a new history snapshot when doc get updated */
|
||||
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
|
||||
//
|
||||
// /* Use `y-octo` to merge updates at the same time when merging using Yjs */
|
||||
// AFFiNE.doc.manager.experimentalMergeWithYOcto = true;
|
||||
//
|
||||
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
|
||||
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
|
||||
//
|
||||
@@ -99,20 +84,20 @@ 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', {
|
||||
// AFFiNE.use('redis', {
|
||||
// /* override options */
|
||||
// });
|
||||
//
|
||||
//
|
||||
// /* Payment Plugin */
|
||||
// AFFiNE.plugins.use('payment', {
|
||||
// AFFiNE.use('payment', {
|
||||
// stripe: { keys: {}, apiVersion: '2023-10-16' },
|
||||
// });
|
||||
//
|
||||
//
|
||||
// /* Cloudflare R2 Plugin */
|
||||
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
|
||||
// AFFiNE.plugins.use('cloudflare-r2', {
|
||||
// AFFiNE.use('cloudflare-r2', {
|
||||
// accountId: '',
|
||||
// credentials: {
|
||||
// accessKeyId: '',
|
||||
@@ -122,17 +107,17 @@ AFFiNE.port = 3010;
|
||||
//
|
||||
// /* AWS S3 Plugin */
|
||||
// /* Enable if you choose to store workspace blobs or user avatars in AWS S3 Storage Service */
|
||||
// AFFiNE.plugins.use('aws-s3', {
|
||||
// AFFiNE.use('aws-s3', {
|
||||
// credentials: {
|
||||
// accessKeyId: '',
|
||||
// secretAccessKey: '',
|
||||
// })
|
||||
// /* Update the provider of storages */
|
||||
// AFFiNE.storage.storages.blob.provider = 'r2';
|
||||
// AFFiNE.storage.storages.avatar.provider = 'r2';
|
||||
// AFFiNE.storages.blob.provider = 'cloudflare-r2';
|
||||
// AFFiNE.storages.avatar.provider = 'cloudflare-r2';
|
||||
//
|
||||
// /* OAuth Plugin */
|
||||
// AFFiNE.plugins.use('oauth', {
|
||||
// AFFiNE.use('oauth', {
|
||||
// providers: {
|
||||
// github: {
|
||||
// clientId: '',
|
||||
@@ -152,5 +137,32 @@ AFFiNE.port = 3010;
|
||||
// access_type: 'offline',
|
||||
// },
|
||||
// },
|
||||
// oidc: {
|
||||
// // OpenID Connect
|
||||
// issuer: '',
|
||||
// clientId: '',
|
||||
// clientSecret: '',
|
||||
// args: {
|
||||
// scope: 'openid email profile',
|
||||
// claim_id: 'preferred_username',
|
||||
// claim_email: 'email',
|
||||
// claim_name: 'name',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// /* Copilot Plugin */
|
||||
// AFFiNE.use('copilot', {
|
||||
// openai: {
|
||||
// apiKey: 'your-key',
|
||||
// },
|
||||
// fal: {
|
||||
// apiKey: 'your-key',
|
||||
// },
|
||||
// unsplashKey: 'your-key',
|
||||
// storage: {
|
||||
// provider: 'cloudflare-r2',
|
||||
// bucket: 'copilot',
|
||||
// }
|
||||
// })
|
||||
|
||||
91
packages/backend/server/src/core/auth/config.ts
Normal file
91
packages/backend/server/src/core/auth/config.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
defineRuntimeConfig,
|
||||
defineStartupConfig,
|
||||
ModuleConfig,
|
||||
} from '../../fundamentals/config';
|
||||
|
||||
export interface AuthStartupConfigurations {
|
||||
/**
|
||||
* auth session config
|
||||
*/
|
||||
session: {
|
||||
/**
|
||||
* Application auth expiration time in seconds
|
||||
*/
|
||||
ttl: number;
|
||||
/**
|
||||
* Application auth time to refresh in seconds
|
||||
*/
|
||||
ttr: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application access token config
|
||||
*/
|
||||
accessToken: {
|
||||
/**
|
||||
* Application access token expiration time in seconds
|
||||
*/
|
||||
ttl: number;
|
||||
/**
|
||||
* Application refresh token expiration time in seconds
|
||||
*/
|
||||
refreshTokenTtl: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthRuntimeConfigurations {
|
||||
/**
|
||||
* Whether allow anonymous users to sign up
|
||||
*/
|
||||
allowSignup: boolean;
|
||||
|
||||
/**
|
||||
* Whether require email verification before access restricted resources
|
||||
*/
|
||||
requireEmailVerification: boolean;
|
||||
|
||||
/**
|
||||
* The minimum and maximum length of the password when registering new users
|
||||
*/
|
||||
password: {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/config' {
|
||||
interface AppConfig {
|
||||
auth: ModuleConfig<AuthStartupConfigurations, AuthRuntimeConfigurations>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('auth', {
|
||||
session: {
|
||||
ttl: 60 * 60 * 24 * 15, // 15 days
|
||||
ttr: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
accessToken: {
|
||||
ttl: 60 * 60 * 24 * 7, // 7 days
|
||||
refreshTokenTtl: 60 * 60 * 24 * 30, // 30 days
|
||||
},
|
||||
});
|
||||
|
||||
defineRuntimeConfig('auth', {
|
||||
allowSignup: {
|
||||
desc: 'Whether allow new registrations',
|
||||
default: true,
|
||||
},
|
||||
requireEmailVerification: {
|
||||
desc: 'Whether require email verification before accessing restricted resources',
|
||||
default: true,
|
||||
},
|
||||
'password.min': {
|
||||
desc: 'The minimum length of user password',
|
||||
default: 8,
|
||||
},
|
||||
'password.max': {
|
||||
desc: 'The maximum length of user password',
|
||||
default: 32,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -16,7 +15,11 @@ import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
Config,
|
||||
PaymentRequiredException,
|
||||
EarlyAccessRequired,
|
||||
EmailTokenNotFound,
|
||||
InternalServerError,
|
||||
InvalidEmailToken,
|
||||
SignUpForbidden,
|
||||
Throttle,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
@@ -60,9 +63,7 @@ export class AuthController {
|
||||
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`
|
||||
);
|
||||
throw new EarlyAccessRequired();
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
@@ -76,8 +77,11 @@ export class AuthController {
|
||||
} else {
|
||||
// send email magic link
|
||||
const user = await this.user.findUserByEmail(credential.email);
|
||||
if (!user && !this.config.auth.allowSignup) {
|
||||
throw new BadRequestException('You are not allows to sign up.');
|
||||
if (!user) {
|
||||
const allowSignup = await this.config.runtime.fetch('auth/allowSignup');
|
||||
if (!allowSignup) {
|
||||
throw new SignUpForbidden();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.sendSignInEmail(
|
||||
@@ -86,7 +90,7 @@ export class AuthController {
|
||||
);
|
||||
|
||||
if (result.rejected.length) {
|
||||
throw new Error('Failed to send sign-in email.');
|
||||
throw new InternalServerError('Failed to send sign-in email.');
|
||||
}
|
||||
|
||||
res.status(HttpStatus.OK).send({
|
||||
@@ -147,7 +151,7 @@ export class AuthController {
|
||||
@Body() { email, token }: MagicLinkCredential
|
||||
) {
|
||||
if (!token || !email) {
|
||||
throw new BadRequestException('Missing sign-in mail token');
|
||||
throw new EmailTokenNotFound();
|
||||
}
|
||||
|
||||
validators.assertValidEmail(email);
|
||||
@@ -157,7 +161,7 @@ export class AuthController {
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Invalid sign-in mail token');
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const user = await this.user.fulfillUser(email, {
|
||||
@@ -198,7 +202,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Get('/challenge')
|
||||
async challenge() {
|
||||
// TODO: impl in following PR
|
||||
// TODO(@darksky): impl in following PR
|
||||
return {
|
||||
challenge: randomUUID(),
|
||||
resource: randomUUID(),
|
||||
|
||||
@@ -3,15 +3,13 @@ import type {
|
||||
ExecutionContext,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
import {
|
||||
AuthenticationRequired,
|
||||
getRequestResponseFromContext,
|
||||
} from '../../fundamentals';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
@@ -22,6 +20,8 @@ function extractTokenFromHeader(authorization: string) {
|
||||
return authorization.substring(7);
|
||||
}
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
@@ -72,9 +72,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
}
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.get<boolean>(
|
||||
'isPublic',
|
||||
context.getHandler()
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
PUBLIC_ENTRYPOINT_SYMBOL,
|
||||
[context.getClass(), context.getHandler()]
|
||||
);
|
||||
|
||||
if (isPublic) {
|
||||
@@ -82,7 +82,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException('You are not signed in.');
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -110,4 +110,4 @@ export const Auth = () => {
|
||||
};
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -10,7 +9,18 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { Config, SkipThrottle, Throttle } from '../../fundamentals';
|
||||
import {
|
||||
ActionForbidden,
|
||||
Config,
|
||||
EmailAlreadyUsed,
|
||||
EmailTokenNotFound,
|
||||
EmailVerificationRequired,
|
||||
InvalidEmailToken,
|
||||
SameEmailProvided,
|
||||
SkipThrottle,
|
||||
Throttle,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
import { UserService } from '../user';
|
||||
import { UserType } from '../user/types';
|
||||
import { validators } from '../utils/validators';
|
||||
@@ -36,6 +46,7 @@ export class ClientTokenType {
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly token: TokenService
|
||||
@@ -61,7 +72,7 @@ export class AuthResolver {
|
||||
@Parent() user: UserType
|
||||
): Promise<ClientTokenType> {
|
||||
if (user.id !== currentUser.id) {
|
||||
throw new ForbiddenException('Invalid user');
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const session = await this.auth.createUserSession(
|
||||
@@ -83,7 +94,14 @@ export class AuthResolver {
|
||||
@Args('token') token: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
validators.assertValidPassword(newPassword);
|
||||
const config = await this.config.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
validators.assertValidPassword(newPassword, {
|
||||
min: config['auth/password.min'],
|
||||
max: config['auth/password.max'],
|
||||
});
|
||||
// NOTE: Set & Change password are using the same token type.
|
||||
const valid = await this.token.verifyToken(
|
||||
TokenType.ChangePassword,
|
||||
@@ -94,7 +112,7 @@ export class AuthResolver {
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
await this.auth.changePassword(user.id, newPassword);
|
||||
@@ -116,7 +134,7 @@ export class AuthResolver {
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
email = decodeURIComponent(email);
|
||||
@@ -136,7 +154,7 @@ export class AuthResolver {
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
throw new EmailVerificationRequired();
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
@@ -144,13 +162,9 @@ export class AuthResolver {
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
const res = await this.auth.sendChangePasswordEmail(user.email, url);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
@@ -162,7 +176,7 @@ export class AuthResolver {
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
throw new EmailVerificationRequired();
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
@@ -170,13 +184,9 @@ export class AuthResolver {
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendSetPasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
const res = await this.auth.sendSetPasswordEmail(user.email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -195,15 +205,14 @@ export class AuthResolver {
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
throw new EmailVerificationRequired();
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendChangeEmail(user.email, url.toString());
|
||||
const res = await this.auth.sendChangeEmail(user.email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -214,24 +223,26 @@ export class AuthResolver {
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
if (!token) {
|
||||
throw new EmailTokenNotFound();
|
||||
}
|
||||
|
||||
validators.assertValidEmail(email);
|
||||
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const hasRegistered = await this.user.findUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
if (hasRegistered.id !== user.id) {
|
||||
throw new BadRequestException(`The email provided has been taken.`);
|
||||
throw new EmailAlreadyUsed();
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
`The email provided is the same as the current email.`
|
||||
);
|
||||
throw new SameEmailProvided();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,11 +251,8 @@ export class AuthResolver {
|
||||
user.id
|
||||
);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', verifyEmailToken);
|
||||
url.searchParams.set('email', email);
|
||||
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
|
||||
const url = this.url.link(callbackUrl, { token: verifyEmailToken, email });
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
@@ -256,10 +264,9 @@ export class AuthResolver {
|
||||
) {
|
||||
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
const url = this.url.link(callbackUrl, { token });
|
||||
|
||||
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
|
||||
const res = await this.auth.sendVerifyEmail(user.email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -269,7 +276,7 @@ export class AuthResolver {
|
||||
@Args('token') token: string
|
||||
) {
|
||||
if (!token) {
|
||||
throw new BadRequestException('Invalid token');
|
||||
throw new EmailTokenNotFound();
|
||||
}
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||
@@ -277,7 +284,7 @@ export class AuthResolver {
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotAcceptableException,
|
||||
OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import type { User } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
|
||||
import { Config, CryptoHelper, MailService } from '../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EmailAlreadyUsed,
|
||||
MailService,
|
||||
WrongSignInCredentials,
|
||||
WrongSignInMethod,
|
||||
} from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { QuotaType } from '../quota/types';
|
||||
@@ -60,7 +63,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
secure: this.config.https,
|
||||
secure: this.config.server.https,
|
||||
};
|
||||
static readonly sessionCookieName = 'affine_session';
|
||||
static readonly authUserSeqHeaderName = 'x-auth-user';
|
||||
@@ -88,6 +91,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
await this.quota.switchUserQuota(devUser.id, QuotaType.ProPlanV1);
|
||||
await this.feature.addAdmin(devUser.id);
|
||||
await this.feature.addCopilot(devUser.id);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
@@ -107,7 +111,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
const user = await this.user.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email was taken');
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
|
||||
const hashedPassword = await this.crypto.encryptPassword(password);
|
||||
@@ -125,13 +129,11 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
const user = await this.user.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
throw new WrongSignInCredentials();
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new NotAcceptableException(
|
||||
'User Password is not set. Should login through email link.'
|
||||
);
|
||||
throw new WrongSignInMethod();
|
||||
}
|
||||
|
||||
const passwordMatches = await this.crypto.verifyPassword(
|
||||
@@ -140,7 +142,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new NotAcceptableException('Invalid sign in credentials');
|
||||
throw new WrongSignInCredentials();
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
@@ -376,55 +378,30 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
): Promise<Omit<User, 'password'>> {
|
||||
const hashedPassword = await this.crypto.encryptPassword(newPassword);
|
||||
|
||||
return this.db.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
return this.user.updateUser(id, { password: hashedPassword });
|
||||
}
|
||||
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.user.findUserById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
return this.db.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
email: newEmail,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
async changeEmail(
|
||||
id: string,
|
||||
newEmail: string
|
||||
): Promise<Omit<User, 'password'>> {
|
||||
return this.user.updateUser(id, {
|
||||
email: newEmail,
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async setEmailVerified(id: string) {
|
||||
return await this.db.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
emailVerifiedAt: true,
|
||||
},
|
||||
});
|
||||
return await this.user.updateUser(
|
||||
id,
|
||||
{ emailVerifiedAt: new Date() },
|
||||
{ emailVerifiedAt: true }
|
||||
);
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
||||
@@ -455,4 +432,23 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
to: email,
|
||||
});
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanExpiredSessions() {
|
||||
await this.db.session.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.userSession.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export class TokenService {
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
cleanExpiredTokens() {
|
||||
return this.db.verificationToken.deleteMany({
|
||||
async cleanExpiredTokens() {
|
||||
await this.db.verificationToken.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lte: new Date(),
|
||||
|
||||
55
packages/backend/server/src/core/common/admin-guard.ts
Normal file
55
packages/backend/server/src/core/common/admin-guard.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, UseGuards } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import {
|
||||
ActionForbidden,
|
||||
getRequestResponseFromContext,
|
||||
} from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate, OnModuleInit {
|
||||
private feature!: FeatureManagementService;
|
||||
|
||||
constructor(private readonly ref: ModuleRef) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.feature = this.ref.get(FeatureManagementService, { strict: false });
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
let allow = false;
|
||||
if (req.user) {
|
||||
allow = await this.feature.isAdmin(req.user.id);
|
||||
}
|
||||
|
||||
if (!allow) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This guard is used to protect routes/queries/mutations that require a user to be administrator.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* \@Admin()
|
||||
* \@Mutation(() => UserType)
|
||||
* createAccount(userInput: UserInput) {
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const Admin = () => {
|
||||
return UseGuards(AdminGuard);
|
||||
};
|
||||
1
packages/backend/server/src/core/common/index.ts
Normal file
1
packages/backend/server/src/core/common/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './admin-guard';
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../fundamentals';
|
||||
import { Public } from './auth';
|
||||
|
||||
export enum ServerFeature {
|
||||
Copilot = 'copilot',
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordLimitsType {
|
||||
@Field()
|
||||
minLength!: number;
|
||||
@Field()
|
||||
maxLength!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CredentialsRequirementType {
|
||||
@Field()
|
||||
password!: PasswordLimitsType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field(() => CredentialsRequirementType, {
|
||||
description: 'credentials requirement',
|
||||
})
|
||||
credentialsRequirement!: CredentialsRequirementType;
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
export class ServerConfigResolver {
|
||||
@Public()
|
||||
@Query(() => ServerConfigType, {
|
||||
description: 'server config',
|
||||
})
|
||||
serverConfig(): ServerConfigType {
|
||||
return {
|
||||
name: AFFiNE.serverName,
|
||||
version: AFFiNE.version,
|
||||
baseUrl: AFFiNE.baseUrl,
|
||||
type: AFFiNE.type,
|
||||
// BACKWARD COMPATIBILITY
|
||||
// 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: Array.from(ENABLED_FEATURES),
|
||||
credentialsRequirement: {
|
||||
password: AFFiNE.auth.password,
|
||||
},
|
||||
enableTelemetry: AFFiNE.telemetry.enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [ServerConfigResolver],
|
||||
})
|
||||
export class ServerConfigModule {}
|
||||
23
packages/backend/server/src/core/config/config.ts
Normal file
23
packages/backend/server/src/core/config/config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineRuntimeConfig, ModuleConfig } from '../../fundamentals/config';
|
||||
|
||||
export interface ServerFlags {
|
||||
earlyAccessControl: boolean;
|
||||
syncClientVersionCheck: boolean;
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/config' {
|
||||
interface AppConfig {
|
||||
flags: ModuleConfig<never, ServerFlags>;
|
||||
}
|
||||
}
|
||||
|
||||
defineRuntimeConfig('flags', {
|
||||
earlyAccessControl: {
|
||||
desc: 'Only allow users with early access features to access the app',
|
||||
default: false,
|
||||
},
|
||||
syncClientVersionCheck: {
|
||||
desc: 'Only allow client with exact the same version with server to establish sync connections',
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
12
packages/backend/server/src/core/config/index.ts
Normal file
12
packages/backend/server/src/core/config/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServerConfigResolver, ServerRuntimeConfigResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [ServerConfigResolver, ServerRuntimeConfigResolver],
|
||||
})
|
||||
export class ServerConfigModule {}
|
||||
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver';
|
||||
export { ServerFeature } from './types';
|
||||
207
packages/backend/server/src/core/config/resolver.ts
Normal file
207
packages/backend/server/src/core/config/resolver.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
GraphQLISODateTime,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { RuntimeConfig, RuntimeConfigType } from '@prisma/client';
|
||||
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
|
||||
|
||||
import { Config, DeploymentType, URLHelper } from '../../fundamentals';
|
||||
import { Public } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { ServerFlags } from './config';
|
||||
import { ServerFeature } from './types';
|
||||
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class PasswordLimitsType {
|
||||
@Field()
|
||||
minLength!: number;
|
||||
@Field()
|
||||
maxLength!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CredentialsRequirementType {
|
||||
@Field()
|
||||
password!: PasswordLimitsType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
@Field({
|
||||
description:
|
||||
'server identical name could be shown as badge on user interface',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@Field({ description: 'server version' })
|
||||
version!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
|
||||
@Field({ description: 'enable telemetry' })
|
||||
enableTelemetry!: boolean;
|
||||
}
|
||||
|
||||
registerEnumType(RuntimeConfigType, {
|
||||
name: 'RuntimeConfigType',
|
||||
});
|
||||
@ObjectType()
|
||||
export class ServerRuntimeConfigType implements Partial<RuntimeConfig> {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
module!: string;
|
||||
|
||||
@Field()
|
||||
key!: string;
|
||||
|
||||
@Field()
|
||||
description!: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
value!: any;
|
||||
|
||||
@Field(() => RuntimeConfigType)
|
||||
type!: RuntimeConfigType;
|
||||
|
||||
@Field(() => GraphQLISODateTime)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ServerFlagsType implements ServerFlags {
|
||||
@Field()
|
||||
earlyAccessControl!: boolean;
|
||||
|
||||
@Field()
|
||||
syncClientVersionCheck!: boolean;
|
||||
}
|
||||
|
||||
@Resolver(() => ServerConfigType)
|
||||
export class ServerConfigResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Query(() => ServerConfigType, {
|
||||
description: 'server config',
|
||||
})
|
||||
serverConfig(): ServerConfigType {
|
||||
return {
|
||||
name: this.config.serverName,
|
||||
version: this.config.version,
|
||||
baseUrl: this.url.home,
|
||||
type: this.config.type,
|
||||
// BACKWARD COMPATIBILITY
|
||||
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||
// this field should be removed after frontend feature flags implemented
|
||||
flavor: this.config.type,
|
||||
features: Array.from(ENABLED_FEATURES),
|
||||
enableTelemetry: this.config.metrics.telemetry.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => CredentialsRequirementType, {
|
||||
description: 'credentials requirement',
|
||||
})
|
||||
async credentialsRequirement() {
|
||||
const config = await this.config.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
|
||||
return {
|
||||
password: {
|
||||
minLength: config['auth/password.min'],
|
||||
maxLength: config['auth/password.max'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => ServerFlagsType, {
|
||||
description: 'server flags',
|
||||
})
|
||||
async flags(): Promise<ServerFlagsType> {
|
||||
const records = await this.config.runtime.list('flags');
|
||||
|
||||
return records.reduce((flags, record) => {
|
||||
flags[record.key as keyof ServerFlagsType] = record.value as any;
|
||||
return flags;
|
||||
}, {} as ServerFlagsType);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => ServerRuntimeConfigType)
|
||||
export class ServerRuntimeConfigResolver {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@Admin()
|
||||
@Query(() => [ServerRuntimeConfigType], {
|
||||
description: 'get all server runtime configurable settings',
|
||||
})
|
||||
serverRuntimeConfig(): Promise<ServerRuntimeConfigType[]> {
|
||||
return this.config.runtime.list();
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => ServerRuntimeConfigType, {
|
||||
description: 'update server runtime configurable setting',
|
||||
})
|
||||
async updateRuntimeConfig(
|
||||
@Args('id') id: string,
|
||||
@Args({ type: () => GraphQLJSON, name: 'value' }) value: any
|
||||
): Promise<ServerRuntimeConfigType> {
|
||||
return await this.config.runtime.set(id as any, value);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => [ServerRuntimeConfigType], {
|
||||
description: 'update multiple server runtime configurable settings',
|
||||
})
|
||||
async updateRuntimeConfigs(
|
||||
@Args({ type: () => GraphQLJSONObject, name: 'updates' }) updates: any
|
||||
): Promise<ServerRuntimeConfigType[]> {
|
||||
const keys = Object.keys(updates);
|
||||
const results = await Promise.all(
|
||||
keys.map(key => this.config.runtime.set(key as any, updates[key]))
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
5
packages/backend/server/src/core/config/types.ts
Normal file
5
packages/backend/server/src/core/config/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum ServerFeature {
|
||||
Copilot = 'copilot',
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
71
packages/backend/server/src/core/doc/config.ts
Normal file
71
packages/backend/server/src/core/doc/config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
defineRuntimeConfig,
|
||||
defineStartupConfig,
|
||||
ModuleConfig,
|
||||
} from '../../fundamentals/config';
|
||||
|
||||
interface DocStartupConfigurations {
|
||||
manager: {
|
||||
/**
|
||||
* Whether auto merge updates into doc snapshot.
|
||||
*/
|
||||
enableUpdateAutoMerging: boolean;
|
||||
|
||||
/**
|
||||
* How often the [DocManager] will start a new turn of merging pending updates into doc snapshot.
|
||||
*
|
||||
* This is not the latency a new joint client will take to see the latest doc,
|
||||
* but the buffer time we introduced to reduce the load of our service.
|
||||
*
|
||||
* in {ms}
|
||||
*/
|
||||
updatePollInterval: number;
|
||||
|
||||
/**
|
||||
* The maximum number of updates that will be pulled from the server at once.
|
||||
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
|
||||
*/
|
||||
maxUpdatesPullCount: number;
|
||||
};
|
||||
history: {
|
||||
/**
|
||||
* How long the buffer time of creating a new history snapshot when doc get updated.
|
||||
*
|
||||
* in {ms}
|
||||
*/
|
||||
interval: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DocRuntimeConfigurations {
|
||||
/**
|
||||
* Use `y-octo` to merge updates at the same time when merging using Yjs.
|
||||
*
|
||||
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
|
||||
*/
|
||||
experimentalMergeWithYOcto: boolean;
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/config' {
|
||||
interface AppConfig {
|
||||
doc: ModuleConfig<DocStartupConfigurations, DocRuntimeConfigurations>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('doc', {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: true,
|
||||
updatePollInterval: 3000,
|
||||
maxUpdatesPullCount: 500,
|
||||
},
|
||||
history: {
|
||||
interval: 1000 * 60 * 10 /* 10 mins */,
|
||||
},
|
||||
});
|
||||
|
||||
defineRuntimeConfig('doc', {
|
||||
experimentalMergeWithYOcto: {
|
||||
desc: 'Use `y-octo` to merge updates at the same time when merging using Yjs.',
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,14 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import type { EventPayload } from '../../fundamentals';
|
||||
import { Config, metrics, OnEvent } from '../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
metrics,
|
||||
OnEvent,
|
||||
WorkspaceNotFound,
|
||||
} from '../../fundamentals';
|
||||
import { QuotaService } from '../quota';
|
||||
import { Permission } from '../workspaces/types';
|
||||
import { isEmptyBuffer } from './manager';
|
||||
@@ -191,7 +198,11 @@ export class DocHistoryManager {
|
||||
});
|
||||
|
||||
if (!history) {
|
||||
throw new Error('Given history not found');
|
||||
throw new DocHistoryNotFound({
|
||||
workspaceId,
|
||||
docId: id,
|
||||
timestamp: timestamp.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
const oldSnapshot = await this.db.snapshot.findUnique({
|
||||
@@ -204,8 +215,7 @@ export class DocHistoryManager {
|
||||
});
|
||||
|
||||
if (!oldSnapshot) {
|
||||
// unreachable actually
|
||||
throw new Error('Given Doc not found');
|
||||
throw new DocNotFound({ workspaceId, docId: id });
|
||||
}
|
||||
|
||||
// save old snapshot as one history record
|
||||
@@ -236,8 +246,7 @@ export class DocHistoryManager {
|
||||
});
|
||||
|
||||
if (!permission) {
|
||||
// unreachable actually
|
||||
throw new Error('Workspace owner not found');
|
||||
throw new WorkspaceNotFound({ workspaceId });
|
||||
}
|
||||
|
||||
const quota = await this.quota.getUserQuota(permission.userId);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QuotaModule } from '../quota';
|
||||
|
||||
@@ -133,8 +133,11 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
|
||||
const doc = await this.recoverDoc(...updates);
|
||||
|
||||
const useYocto = await this.config.runtime.fetch(
|
||||
'doc/experimentalMergeWithYOcto'
|
||||
);
|
||||
// test jwst codec
|
||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||
if (useYocto) {
|
||||
metrics.jwst.counter('codec_merge_counter').add(1);
|
||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||
let log = false;
|
||||
@@ -185,11 +188,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
}, this.config.doc.manager.updatePollInterval);
|
||||
|
||||
this.logger.log('Automation started');
|
||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||
this.logger.warn(
|
||||
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { PrismaTransaction } from '../../fundamentals';
|
||||
import { Feature, FeatureSchema, FeatureType } from './types';
|
||||
|
||||
class FeatureConfig {
|
||||
readonly config: Feature;
|
||||
class FeatureConfig<T extends FeatureType> {
|
||||
readonly config: Feature & { feature: T };
|
||||
|
||||
constructor(data: any) {
|
||||
const config = FeatureSchema.safeParse(data);
|
||||
|
||||
if (config.success) {
|
||||
// @ts-expect-error allow
|
||||
this.config = config.data;
|
||||
} else {
|
||||
throw new Error(`Invalid quota config: ${config.error.message}`);
|
||||
@@ -19,86 +21,18 @@ class FeatureConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.Copilot };
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.Copilot) {
|
||||
throw new Error('Invalid feature config: type is not Copilot');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EarlyAccessFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.EarlyAccess };
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.EarlyAccess) {
|
||||
throw new Error('Invalid feature config: type is not EarlyAccess');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.UnlimitedWorkspace };
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.UnlimitedWorkspace) {
|
||||
throw new Error('Invalid feature config: type is not UnlimitedWorkspace');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class UnlimitedCopilotFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.UnlimitedCopilot };
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.UnlimitedCopilot) {
|
||||
throw new Error('Invalid feature config: type is not AIEarlyAccess');
|
||||
}
|
||||
}
|
||||
}
|
||||
export class AIEarlyAccessFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.AIEarlyAccess };
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.AIEarlyAccess) {
|
||||
throw new Error('Invalid feature config: type is not AIEarlyAccess');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureConfigMap = {
|
||||
[FeatureType.Copilot]: CopilotFeatureConfig,
|
||||
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
|
||||
[FeatureType.AIEarlyAccess]: AIEarlyAccessFeatureConfig,
|
||||
[FeatureType.UnlimitedWorkspace]: UnlimitedWorkspaceFeatureConfig,
|
||||
[FeatureType.UnlimitedCopilot]: UnlimitedCopilotFeatureConfig,
|
||||
};
|
||||
|
||||
export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
||||
(typeof FeatureConfigMap)[F]
|
||||
>;
|
||||
export type FeatureConfigType<F extends FeatureType> = FeatureConfig<F>;
|
||||
|
||||
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
|
||||
|
||||
export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
const cachedQuota = FeatureCache.get(featureId);
|
||||
const cachedFeature = FeatureCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
return cachedQuota;
|
||||
if (cachedFeature) {
|
||||
return cachedFeature;
|
||||
}
|
||||
|
||||
const feature = await prisma.features.findFirst({
|
||||
const feature = await prisma.feature.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
@@ -107,13 +41,8 @@ export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
// this should unreachable
|
||||
throw new Error(`Quota config ${featureId} not found`);
|
||||
}
|
||||
const ConfigClass = FeatureConfigMap[feature.feature as FeatureType];
|
||||
|
||||
if (!ConfigClass) {
|
||||
throw new Error(`Feature config ${featureId} not found`);
|
||||
}
|
||||
|
||||
const config = new ConfigClass(feature);
|
||||
const config = new FeatureConfig(feature);
|
||||
// we always edit quota config as a new quota config
|
||||
// so we can cache it by featureId
|
||||
FeatureCache.set(featureId, config);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UserModule } from '../user';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureManagementResolver } from './resolver';
|
||||
import { FeatureService } from './service';
|
||||
|
||||
/**
|
||||
@@ -10,7 +12,12 @@ import { FeatureService } from './service';
|
||||
* - feature statistics
|
||||
*/
|
||||
@Module({
|
||||
providers: [FeatureService, FeatureManagementService],
|
||||
imports: [UserModule],
|
||||
providers: [
|
||||
FeatureService,
|
||||
FeatureManagementService,
|
||||
FeatureManagementResolver,
|
||||
],
|
||||
exports: [FeatureService, FeatureManagementService],
|
||||
})
|
||||
export class FeatureModule {}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../fundamentals';
|
||||
import { UserService } from '../user/service';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
const STAFF = ['@toeverything.info'];
|
||||
const STAFF = ['@toeverything.info', '@affine.pro'];
|
||||
|
||||
export enum EarlyAccessType {
|
||||
App = 'app',
|
||||
@@ -18,22 +18,30 @@ export class FeatureManagementService {
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly user: UserService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
// ======== Admin ========
|
||||
|
||||
// todo(@darkskygit): replace this with abac
|
||||
isStaff(email: string) {
|
||||
for (const domain of STAFF) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
isAdmin(userId: string) {
|
||||
return this.feature.hasUserFeature(userId, FeatureType.Admin);
|
||||
}
|
||||
|
||||
addAdmin(userId: string) {
|
||||
return this.feature.addUserFeature(userId, FeatureType.Admin, 'Admin user');
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
async addEarlyAccess(
|
||||
userId: string,
|
||||
@@ -69,31 +77,17 @@ export class FeatureManagementService {
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(
|
||||
email: string,
|
||||
userId: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const canEarlyAccess = await this.feature
|
||||
.hasUserFeature(
|
||||
user.id,
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
)
|
||||
.catch(() => false);
|
||||
|
||||
return canEarlyAccess;
|
||||
}
|
||||
return false;
|
||||
return await this.feature
|
||||
.hasUserFeature(
|
||||
userId,
|
||||
type === EarlyAccessType.App
|
||||
? FeatureType.EarlyAccess
|
||||
: FeatureType.AIEarlyAccess
|
||||
)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
/// check early access by email
|
||||
@@ -101,8 +95,16 @@ export class FeatureManagementService {
|
||||
email: string,
|
||||
type: EarlyAccessType = EarlyAccessType.App
|
||||
) {
|
||||
if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) {
|
||||
return this.isEarlyAccessUser(email, type);
|
||||
const earlyAccessControlEnabled = await this.config.runtime.fetch(
|
||||
'flags/earlyAccessControl'
|
||||
);
|
||||
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.user.findUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return this.isEarlyAccessUser(user.id, type);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { UserNotFound } from '../../fundamentals';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { EarlyAccessType, FeatureManagementService } from '../features';
|
||||
import { UserService } from './service';
|
||||
import { UserType } from './types';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user/service';
|
||||
import { UserType } from '../user/types';
|
||||
import { EarlyAccessType, FeatureManagementService } from './management';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
name: 'EarlyAccessType',
|
||||
});
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
export class FeatureManagementResolver {
|
||||
constructor(
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
description: 'Enabled features of a user',
|
||||
})
|
||||
async userFeatures(@Parent() user: UserType) {
|
||||
return this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string,
|
||||
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id, type);
|
||||
@@ -46,33 +54,39 @@ export class UserManagementResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User ${email} not found`);
|
||||
throw new UserNotFound();
|
||||
}
|
||||
return this.feature.removeEarlyAccess(user.id);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() user: CurrentUser
|
||||
@Context() ctx: { isAdminQuery: boolean }
|
||||
): 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().then(users => {
|
||||
return users.map(sessionUser);
|
||||
});
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Boolean)
|
||||
async addAdminister(@Args('email') email: string): Promise<boolean> {
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
await this.feature.addAdmin(user.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,9 @@ import { FeatureKind, FeatureType } from './types';
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
async getFeature<F extends FeatureType>(
|
||||
feature: F
|
||||
): Promise<FeatureConfigType<F> | undefined> {
|
||||
const data = await this.prisma.features.findFirst({
|
||||
|
||||
async getFeature<F extends FeatureType>(feature: F) {
|
||||
const data = await this.prisma.feature.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
@@ -21,8 +20,9 @@ export class FeatureService {
|
||||
version: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (data) {
|
||||
return getFeature(this.prisma, data.id) as FeatureConfigType<F>;
|
||||
return getFeature(this.prisma, data.id) as Promise<FeatureConfigType<F>>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class FeatureService {
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
const latestFlag = await tx.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -53,7 +53,7 @@ export class FeatureService {
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
const featureId = await tx.features
|
||||
const featureId = await tx.feature
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
orderBy: { version: 'desc' },
|
||||
@@ -65,7 +65,7 @@ export class FeatureService {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.userFeatures
|
||||
return tx.userFeature
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
@@ -81,7 +81,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async removeUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
return this.prisma.userFeature
|
||||
.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -104,7 +104,7 @@ export class FeatureService {
|
||||
* @returns list of features
|
||||
*/
|
||||
async getUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeatures.findMany({
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
@@ -129,7 +129,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async getActivatedUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeatures.findMany({
|
||||
const features = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: { type: FeatureKind.Feature },
|
||||
@@ -156,7 +156,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
return this.prisma.userFeature
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
@@ -182,7 +182,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
return this.prisma.userFeature
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
@@ -206,7 +206,7 @@ export class FeatureService {
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.workspaceFeatures.findFirst({
|
||||
const latestFlag = await tx.workspaceFeature.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
@@ -223,7 +223,7 @@ export class FeatureService {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
// use latest version of feature
|
||||
const featureId = await tx.features
|
||||
const featureId = await tx.feature
|
||||
.findFirst({
|
||||
where: { feature, type: FeatureKind.Feature },
|
||||
select: { id: true },
|
||||
@@ -235,7 +235,7 @@ export class FeatureService {
|
||||
throw new Error(`Feature ${feature} not found`);
|
||||
}
|
||||
|
||||
return tx.workspaceFeatures
|
||||
return tx.workspaceFeature
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
@@ -251,7 +251,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeatures
|
||||
return this.prisma.workspaceFeature
|
||||
.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
@@ -274,7 +274,7 @@ export class FeatureService {
|
||||
* @returns list of features
|
||||
*/
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.prisma.workspaceFeatures.findMany({
|
||||
const features = await this.prisma.workspaceFeature.findMany({
|
||||
where: {
|
||||
workspace: { id: workspaceId },
|
||||
feature: {
|
||||
@@ -301,7 +301,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||
return this.prisma.workspaceFeatures
|
||||
return this.prisma.workspaceFeature
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
@@ -324,7 +324,7 @@ export class FeatureService {
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeatures
|
||||
return this.prisma.workspaceFeature
|
||||
.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
|
||||
8
packages/backend/server/src/core/features/types/admin.ts
Normal file
8
packages/backend/server/src/core/features/types/admin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureAdministrator = z.object({
|
||||
feature: z.literal(FeatureType.Admin),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum FeatureType {
|
||||
// user feature
|
||||
Admin = 'administrator',
|
||||
EarlyAccess = 'early_access',
|
||||
AIEarlyAccess = 'ai_early_access',
|
||||
UnlimitedCopilot = 'unlimited_copilot',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { featureAdministrator } from './admin';
|
||||
import { FeatureType } from './common';
|
||||
import { featureCopilot } from './copilot';
|
||||
import { featureAIEarlyAccess, featureEarlyAccess } from './early-access';
|
||||
@@ -65,6 +66,12 @@ export const Features: Feature[] = [
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.Admin,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
];
|
||||
|
||||
/// ======== schema infer ========
|
||||
@@ -80,6 +87,7 @@ export const FeatureSchema = commonFeatureSchema
|
||||
featureAIEarlyAccess,
|
||||
featureUnlimitedWorkspace,
|
||||
featureUnlimitedCopilot,
|
||||
featureAdministrator,
|
||||
])
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { FeatureModule } from '../features';
|
||||
import { StorageModule } from '../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
import { QuotaManagementResolver } from './resolver';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaManagementService } from './storage';
|
||||
|
||||
@@ -14,7 +15,12 @@ import { QuotaManagementService } from './storage';
|
||||
*/
|
||||
@Module({
|
||||
imports: [FeatureModule, StorageModule],
|
||||
providers: [PermissionService, QuotaService, QuotaManagementService],
|
||||
providers: [
|
||||
PermissionService,
|
||||
QuotaService,
|
||||
QuotaManagementResolver,
|
||||
QuotaManagementService,
|
||||
],
|
||||
exports: [QuotaService, QuotaManagementService],
|
||||
})
|
||||
export class QuotaModule {}
|
||||
|
||||
@@ -13,7 +13,7 @@ export class QuotaConfig {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const quota = await tx.features.findFirst({
|
||||
const quota = await tx.feature.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
|
||||
68
packages/backend/server/src/core/quota/resolver.ts
Normal file
68
packages/backend/server/src/core/quota/resolver.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Field,
|
||||
ObjectType,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { EarlyAccessType } from '../features';
|
||||
import { UserType } from '../user';
|
||||
import { QuotaService } from './service';
|
||||
|
||||
registerEnumType(EarlyAccessType, {
|
||||
name: 'EarlyAccessType',
|
||||
});
|
||||
|
||||
@ObjectType('UserQuotaHumanReadable')
|
||||
class UserQuotaHumanReadableType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field({ name: 'blobLimit' })
|
||||
blobLimit!: string;
|
||||
|
||||
@Field({ name: 'storageQuota' })
|
||||
storageQuota!: string;
|
||||
|
||||
@Field({ name: 'historyPeriod' })
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType('UserQuota')
|
||||
class UserQuotaType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'blobLimit' })
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'storageQuota' })
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: number;
|
||||
|
||||
@Field({ name: 'humanReadable' })
|
||||
humanReadable!: UserQuotaHumanReadableType;
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class QuotaManagementResolver {
|
||||
constructor(private readonly quota: QuotaService) {}
|
||||
|
||||
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
|
||||
async getQuota(@CurrentUser() me: UserType) {
|
||||
const quota = await this.quota.getUserQuota(me.id);
|
||||
|
||||
return quota.feature;
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,25 @@ export const Quotas: Quota[] = [
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.LifetimeProPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Lifetime Pro',
|
||||
// single blob limit 100MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 1TB
|
||||
storageQuota: 1024 * OneGB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
memberLimit: 10,
|
||||
// copilot action limit 10
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getLatestQuota(type: QuotaType) {
|
||||
@@ -165,6 +184,7 @@ export function getLatestQuota(type: QuotaType) {
|
||||
|
||||
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
|
||||
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
|
||||
export const LifetimeProPlan = getLatestQuota(QuotaType.LifetimeProPlanV1);
|
||||
|
||||
export const Quota_FreePlanV1_1 = {
|
||||
feature: Quotas[5].feature,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import type { EventPayload } from '../../fundamentals';
|
||||
import { OnEvent, PrismaTransaction } from '../../fundamentals';
|
||||
import { SubscriptionPlan } from '../../plugins/payment/types';
|
||||
import { FeatureKind, FeatureManagementService } from '../features';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { QuotaConfig } from './quota';
|
||||
import { QuotaType } from './types';
|
||||
|
||||
@@ -17,7 +17,7 @@ export class QuotaService {
|
||||
|
||||
// get activated user quota
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.prisma.userFeatures.findFirst({
|
||||
const quota = await this.prisma.userFeature.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -44,7 +44,7 @@ export class QuotaService {
|
||||
|
||||
// get user all quota records
|
||||
async getUserQuotas(userId: string) {
|
||||
const quotas = await this.prisma.userFeatures.findMany({
|
||||
const quotas = await this.prisma.userFeature.findMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
@@ -58,6 +58,9 @@ export class QuotaService {
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
});
|
||||
const configs = await Promise.all(
|
||||
quotas.map(async quota => {
|
||||
@@ -92,7 +95,7 @@ export class QuotaService {
|
||||
return;
|
||||
}
|
||||
|
||||
const featureId = await tx.features
|
||||
const featureId = await tx.feature
|
||||
.findFirst({
|
||||
where: { feature: quota, type: FeatureKind.Quota },
|
||||
select: { id: true },
|
||||
@@ -105,7 +108,7 @@ export class QuotaService {
|
||||
}
|
||||
|
||||
// we will deactivate all exists quota for this user
|
||||
await tx.userFeatures.updateMany({
|
||||
await tx.userFeature.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId,
|
||||
@@ -118,7 +121,7 @@ export class QuotaService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeatures.create({
|
||||
await tx.userFeature.create({
|
||||
data: {
|
||||
userId,
|
||||
featureId,
|
||||
@@ -133,7 +136,7 @@ export class QuotaService {
|
||||
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||
const executor = tx ?? this.prisma;
|
||||
|
||||
return executor.userFeatures
|
||||
return executor.userFeature
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
@@ -151,15 +154,18 @@ export class QuotaService {
|
||||
async onSubscriptionUpdated({
|
||||
userId,
|
||||
plan,
|
||||
recurring,
|
||||
}: EventPayload<'user.subscription.activated'>) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
case 'ai':
|
||||
await this.feature.addCopilot(userId, 'subscription activated');
|
||||
break;
|
||||
case SubscriptionPlan.Pro:
|
||||
case 'pro':
|
||||
await this.switchUserQuota(
|
||||
userId,
|
||||
QuotaType.ProPlanV1,
|
||||
recurring === 'lifetime'
|
||||
? QuotaType.LifetimeProPlanV1
|
||||
: QuotaType.ProPlanV1,
|
||||
'subscription activated'
|
||||
);
|
||||
break;
|
||||
@@ -174,16 +180,22 @@ export class QuotaService {
|
||||
plan,
|
||||
}: EventPayload<'user.subscription.canceled'>) {
|
||||
switch (plan) {
|
||||
case SubscriptionPlan.AI:
|
||||
case 'ai':
|
||||
await this.feature.removeCopilot(userId);
|
||||
break;
|
||||
case SubscriptionPlan.Pro:
|
||||
await this.switchUserQuota(
|
||||
userId,
|
||||
QuotaType.FreePlanV1,
|
||||
'subscription canceled'
|
||||
);
|
||||
case 'pro': {
|
||||
// edge case: when user switch from recurring Pro plan to `Lifetime` plan,
|
||||
// a subscription canceled event will be triggered because `Lifetime` plan is not subscription based
|
||||
const quota = await this.getUserQuota(userId);
|
||||
if (quota.feature.name !== QuotaType.LifetimeProPlanV1) {
|
||||
await this.switchUserQuota(
|
||||
userId,
|
||||
QuotaType.FreePlanV1,
|
||||
'subscription canceled'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceOwnerNotFound } from '../../fundamentals';
|
||||
import { FeatureService, FeatureType } from '../features';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
@@ -40,7 +41,6 @@ export class QuotaManagementService {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: lazy calc, need to be optimized with cache
|
||||
async getUserUsage(userId: string) {
|
||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||
|
||||
@@ -115,7 +115,7 @@ export class QuotaManagementService {
|
||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||
const { user: owner } =
|
||||
await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
if (!owner) throw new NotFoundException('Workspace owner not found');
|
||||
if (!owner) throw new WorkspaceOwnerNotFound({ workspaceId });
|
||||
const {
|
||||
feature: {
|
||||
name,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ByteUnit, OneDay, OneKB } from './constant';
|
||||
export enum QuotaType {
|
||||
FreePlanV1 = 'free_plan_v1',
|
||||
ProPlanV1 = 'pro_plan_v1',
|
||||
LifetimeProPlanV1 = 'lifetime_pro_plan_v1',
|
||||
// only for test, smaller quota
|
||||
RestrictedPlanV1 = 'restricted_plan_v1',
|
||||
}
|
||||
@@ -25,6 +26,7 @@ const quotaPlan = z.object({
|
||||
feature: z.enum([
|
||||
QuotaType.FreePlanV1,
|
||||
QuotaType.ProPlanV1,
|
||||
QuotaType.LifetimeProPlanV1,
|
||||
QuotaType.RestrictedPlanV1,
|
||||
]),
|
||||
configs: z.object({
|
||||
|
||||
34
packages/backend/server/src/core/storage/config.ts
Normal file
34
packages/backend/server/src/core/storage/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
|
||||
import { StorageProviderType } from '../../fundamentals/storage';
|
||||
|
||||
export type StorageConfig<Ext = unknown> = {
|
||||
provider: StorageProviderType;
|
||||
bucket: string;
|
||||
} & Ext;
|
||||
|
||||
export interface StorageStartupConfigurations {
|
||||
avatar: StorageConfig<{
|
||||
publicLinkFactory: (key: string) => string;
|
||||
keyInPublicLink: (link: string) => string;
|
||||
}>;
|
||||
blob: StorageConfig;
|
||||
}
|
||||
|
||||
declare module '../../fundamentals/config' {
|
||||
interface AppConfig {
|
||||
storages: ModuleConfig<StorageStartupConfigurations>;
|
||||
}
|
||||
}
|
||||
|
||||
defineStartupConfig('storages', {
|
||||
avatar: {
|
||||
provider: 'fs',
|
||||
bucket: 'avatars',
|
||||
publicLinkFactory: key => `/api/avatars/${key}`,
|
||||
keyInPublicLink: link => link.split('/').pop() as string,
|
||||
},
|
||||
blob: {
|
||||
provider: 'fs',
|
||||
bucket: 'blobs',
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import './config';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
|
||||
|
||||
@@ -6,19 +6,25 @@ import type {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
OnEvent,
|
||||
StorageProviderFactory,
|
||||
URLHelper,
|
||||
} from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class AvatarStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
private readonly storageConfig: Config['storage']['storages']['avatar'];
|
||||
private readonly storageConfig: Config['storages']['avatar'];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper,
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
) {
|
||||
this.provider = this.storageFactory.create('avatar');
|
||||
this.storageConfig = this.config.storage.storages.avatar;
|
||||
this.storageConfig = this.config.storages.avatar;
|
||||
this.provider = this.storageFactory.create(this.storageConfig);
|
||||
}
|
||||
|
||||
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
|
||||
@@ -26,7 +32,7 @@ export class AvatarStorage {
|
||||
let link = this.storageConfig.publicLinkFactory(key);
|
||||
|
||||
if (link.startsWith('/')) {
|
||||
link = this.config.baseUrl + link;
|
||||
link = this.url.link(link);
|
||||
}
|
||||
|
||||
return link;
|
||||
@@ -36,8 +42,8 @@ export class AvatarStorage {
|
||||
return this.provider.get(key);
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
return this.provider.delete(key);
|
||||
delete(link: string) {
|
||||
return this.provider.delete(this.storageConfig.keyInPublicLink(link));
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
type BlobInputType,
|
||||
Cache,
|
||||
Config,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
type ListObjectsMetadata,
|
||||
@@ -16,11 +17,12 @@ export class WorkspaceBlobStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly storageFactory: StorageProviderFactory,
|
||||
private readonly cache: Cache
|
||||
) {
|
||||
this.provider = this.storageFactory.create('blob');
|
||||
this.provider = this.storageFactory.create(this.config.storages.blob);
|
||||
}
|
||||
|
||||
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
export enum EventErrorCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
|
||||
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
INTERNAL = 'INTERNAL',
|
||||
VERSION_REJECTED = 'VERSION_REJECTED',
|
||||
}
|
||||
|
||||
// Such errore are generally raised from the gateway handling to user,
|
||||
// the stack must be full of internal code,
|
||||
// so there is no need to inherit from `Error` class.
|
||||
export class EventError {
|
||||
constructor(
|
||||
public readonly code: EventErrorCode,
|
||||
public readonly message: string
|
||||
) {}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceNotFoundError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.WORKSPACE_NOT_FOUND,
|
||||
`You are trying to access an unknown workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocNotFoundError extends EventError {
|
||||
constructor(
|
||||
public readonly workspaceId: string,
|
||||
public readonly docId: string
|
||||
) {
|
||||
super(
|
||||
EventErrorCode.DOC_NOT_FOUND,
|
||||
`You are trying to access an unknown doc ${docId} under workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotInWorkspaceError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.NOT_IN_WORKSPACE,
|
||||
`You should join in workspace ${workspaceId} before broadcasting messages.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessDeniedError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.ACCESS_DENIED,
|
||||
`You have no permission to access workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends EventError {
|
||||
constructor(public readonly error: Error) {
|
||||
super(EventErrorCode.INTERNAL, `Internal error happened: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionRejectedError extends EventError {
|
||||
constructor(public readonly version: number) {
|
||||
super(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
// TODO: Too general error message,
|
||||
// need to be more specific when versioning system is implemented.
|
||||
`The version ${version} is rejected by server.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,73 +11,36 @@ import {
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
import { CallTimer, metrics } from '../../../fundamentals';
|
||||
import {
|
||||
CallTimer,
|
||||
Config,
|
||||
DocNotFound,
|
||||
GatewayErrorWrapper,
|
||||
metrics,
|
||||
NotInWorkspace,
|
||||
VersionRejected,
|
||||
WorkspaceAccessDenied,
|
||||
} from '../../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocManager } from '../../doc';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService } from '../../workspaces/permission';
|
||||
import { Permission } from '../../workspaces/types';
|
||||
import {
|
||||
AccessDeniedError,
|
||||
DocNotFoundError,
|
||||
EventError,
|
||||
EventErrorCode,
|
||||
InternalError,
|
||||
NotInWorkspaceError,
|
||||
} from './error';
|
||||
|
||||
export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
// @ts-expect-error allow
|
||||
return (
|
||||
_target,
|
||||
_key,
|
||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
) => {
|
||||
const originalMethod = desc.value;
|
||||
if (!originalMethod) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = async function (...args: any[]) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (e) {
|
||||
if (e instanceof EventError) {
|
||||
return {
|
||||
error: e,
|
||||
};
|
||||
} else {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
new Logger('EventsGateway').error(e, (e as Error).stack);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(
|
||||
GatewayErrorWrapper(),
|
||||
GatewayErrorWrapper(event),
|
||||
CallTimer('socketio', 'event_duration', { event }),
|
||||
RawSubscribeMessage(event)
|
||||
);
|
||||
|
||||
type EventResponse<Data = any> =
|
||||
| {
|
||||
error: EventError;
|
||||
type EventResponse<Data = any> = Data extends never
|
||||
? {
|
||||
data?: never;
|
||||
}
|
||||
| (Data extends never
|
||||
? {
|
||||
data?: never;
|
||||
}
|
||||
: {
|
||||
data: Data;
|
||||
});
|
||||
: {
|
||||
data: Data;
|
||||
};
|
||||
|
||||
function Sync(workspaceId: string): `${string}:sync` {
|
||||
return `${workspaceId}:sync`;
|
||||
@@ -98,6 +61,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private connectionCount = 0;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly docManager: DocManager,
|
||||
private readonly permissions: PermissionService
|
||||
) {}
|
||||
@@ -115,10 +79,13 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
assertVersion(client: Socket, version?: string) {
|
||||
async assertVersion(client: Socket, version?: string) {
|
||||
const shouldCheckClientVersion = await this.config.runtime.fetch(
|
||||
'flags/syncClientVersionCheck'
|
||||
);
|
||||
if (
|
||||
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
||||
AFFiNE.featureFlags.syncClientVersionCheck &&
|
||||
shouldCheckClientVersion &&
|
||||
version !== AFFiNE.version
|
||||
) {
|
||||
client.emit('server-version-rejected', {
|
||||
@@ -129,10 +96,10 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
} is outdated, please update to ${AFFiNE.version}`,
|
||||
});
|
||||
|
||||
throw new EventError(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||
);
|
||||
throw new VersionRejected({
|
||||
version: version || 'unknown',
|
||||
serverVersion: AFFiNE.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +119,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
||||
if (!client.rooms.has(room)) {
|
||||
throw new NotInWorkspaceError(room);
|
||||
throw new NotInWorkspace({ workspaceId: room.split(':')[0] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +135,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
permission
|
||||
))
|
||||
) {
|
||||
throw new AccessDeniedError(workspaceId);
|
||||
throw new WorkspaceAccessDenied({ workspaceId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +147,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
this.assertVersion(client, version);
|
||||
await this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
workspaceId,
|
||||
user.id,
|
||||
@@ -203,7 +170,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
this.assertVersion(client, version);
|
||||
await this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
workspaceId,
|
||||
user.id,
|
||||
@@ -314,9 +281,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
const res = await this.docManager.get(docId.workspace, docId.guid);
|
||||
|
||||
if (!res) {
|
||||
return {
|
||||
error: new DocNotFoundError(workspaceId, docId.guid),
|
||||
};
|
||||
throw new DocNotFound({ workspaceId, docId: docId.guid });
|
||||
}
|
||||
|
||||
const missing = Buffer.from(
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ActionForbidden, UserAvatarNotFound } from '../../fundamentals';
|
||||
import { Public } from '../auth/guard';
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@Public()
|
||||
@Controller('/api/avatars')
|
||||
export class UserAvatarController {
|
||||
constructor(private readonly storage: AvatarStorage) {}
|
||||
@@ -17,7 +13,7 @@ export class UserAvatarController {
|
||||
@Get('/:id')
|
||||
async getAvatar(@Res() res: Response, @Param('id') id: string) {
|
||||
if (this.storage.provider.type !== 'fs') {
|
||||
throw new ForbiddenException(
|
||||
throw new ActionForbidden(
|
||||
'Only available when avatar storage provider set to fs.'
|
||||
);
|
||||
}
|
||||
@@ -25,7 +21,7 @@ export class UserAvatarController {
|
||||
const { body, metadata } = await this.storage.get(id);
|
||||
|
||||
if (!body) {
|
||||
throw new NotFoundException(`Avatar ${id} not found.`);
|
||||
throw new UserAvatarNotFound();
|
||||
}
|
||||
|
||||
// metadata should always exists if body is not null
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserManagementResolver } from './management';
|
||||
import { UserResolver } from './resolver';
|
||||
import { UserManagementResolver, UserResolver } from './resolver';
|
||||
import { UserService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||
providers: [UserResolver, UserManagementResolver, UserService],
|
||||
imports: [StorageModule],
|
||||
providers: [UserResolver, UserService, UserManagementResolver],
|
||||
controllers: [UserAvatarController],
|
||||
exports: [UserService],
|
||||
})
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
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 {
|
||||
EventEmitter,
|
||||
PaymentRequiredException,
|
||||
Config,
|
||||
CryptoHelper,
|
||||
type FileUpload,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../fundamentals';
|
||||
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 { Admin } from '../common';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import { validators } from '../utils/validators';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
UserQuotaType,
|
||||
UserType,
|
||||
} from './types';
|
||||
|
||||
@@ -39,10 +39,7 @@ export class UserResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly users: UserService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly event: EventEmitter
|
||||
private readonly users: UserService
|
||||
) {}
|
||||
|
||||
@Throttle('strict')
|
||||
@@ -53,16 +50,12 @@ export class UserResolver {
|
||||
})
|
||||
@Public()
|
||||
async user(
|
||||
@CurrentUser() currentUser?: CurrentUser,
|
||||
@Args('email') email?: string
|
||||
@Args('email') email: string,
|
||||
@CurrentUser() currentUser?: CurrentUser
|
||||
): 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`
|
||||
);
|
||||
}
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
// TODO(@forehalo): need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
// return empty response when user not exists
|
||||
@@ -79,13 +72,6 @@ export class UserResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
|
||||
async getQuota(@CurrentUser() me: User) {
|
||||
const quota = await this.quota.getUserQuota(me.id);
|
||||
|
||||
return quota.feature;
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
name: 'invoiceCount',
|
||||
description: 'Get user invoice count',
|
||||
@@ -96,14 +82,6 @@ export class UserResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
description: 'Enabled features of a user',
|
||||
})
|
||||
async userFeatures(@CurrentUser() user: CurrentUser) {
|
||||
return this.feature.getActivatedUserFeatures(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
name: 'uploadAvatar',
|
||||
description: 'Upload user avatar',
|
||||
@@ -113,24 +91,27 @@ export class UserResolver {
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
if (!avatar.mimetype.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
const link = await this.storage.put(
|
||||
`${user.id}-avatar`,
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
const avatarUrl = await this.storage.put(
|
||||
`${user.id}-avatar-${Date.now()}`,
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
avatarUrl: link,
|
||||
},
|
||||
});
|
||||
if (user.avatarUrl) {
|
||||
await this.storage.delete(user.avatarUrl);
|
||||
}
|
||||
|
||||
return this.users.updateUser(user.id, { avatarUrl });
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
@@ -146,12 +127,7 @@ export class UserResolver {
|
||||
return user;
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: input,
|
||||
})
|
||||
);
|
||||
return sessionUser(await this.users.updateUser(user.id, input));
|
||||
}
|
||||
|
||||
@Mutation(() => RemoveAvatar, {
|
||||
@@ -160,12 +136,9 @@ export class UserResolver {
|
||||
})
|
||||
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
throw new UserNotFound();
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
await this.users.updateUser(user.id, { avatarUrl: null });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -173,8 +146,110 @@ export class UserResolver {
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<DeleteAccount> {
|
||||
const deletedUser = await this.users.deleteUser(user.id);
|
||||
this.event.emit('user.deleted', deletedUser);
|
||||
await this.users.deleteUser(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class ListUserInput {
|
||||
@Field(() => Int, { nullable: true, defaultValue: 0 })
|
||||
skip!: number;
|
||||
|
||||
@Field(() => Int, { nullable: true, defaultValue: 20 })
|
||||
first!: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class CreateUserInput {
|
||||
@Field(() => String)
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
password!: string | null;
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly user: UserService,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
description: 'List registered users',
|
||||
})
|
||||
async users(
|
||||
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
|
||||
): Promise<UserType[]> {
|
||||
const users = await this.db.user.findMany({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
skip: input.skip,
|
||||
take: input.first,
|
||||
});
|
||||
|
||||
return users.map(sessionUser);
|
||||
}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'userById',
|
||||
description: 'Get user by id',
|
||||
})
|
||||
async getUser(@Args('id') id: string) {
|
||||
const user = await this.db.user.findUnique({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
description: 'Create a new user',
|
||||
})
|
||||
async createUser(
|
||||
@Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput
|
||||
) {
|
||||
validators.assertValidEmail(input.email);
|
||||
if (input.password) {
|
||||
const config = await this.config.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
validators.assertValidPassword(input.password, {
|
||||
max: config['auth/password.max'],
|
||||
min: config['auth/password.min'],
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = await this.user.createAnonymousUser(input.email, {
|
||||
password: input.password
|
||||
? await this.crypto.encryptPassword(input.password)
|
||||
: undefined,
|
||||
registered: true,
|
||||
});
|
||||
|
||||
// data returned by `createUser` does not satisfies `UserType`
|
||||
return this.getUser(id);
|
||||
}
|
||||
|
||||
@Mutation(() => DeleteAccount, {
|
||||
description: 'Delete a user account',
|
||||
})
|
||||
async deleteUser(@Args('id') id: string): Promise<DeleteAccount> {
|
||||
await this.user.deleteUser(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
Config,
|
||||
EmailAlreadyUsed,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
OnEvent,
|
||||
} from '../../fundamentals';
|
||||
import { Quota_FreePlanV1_1 } from '../quota/schema';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
defaultUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
@@ -12,9 +21,14 @@ export class UserService {
|
||||
emailVerifiedAt: true,
|
||||
avatarUrl: true,
|
||||
registered: true,
|
||||
createdAt: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly emitter: EventEmitter
|
||||
) {}
|
||||
|
||||
get userCreatingData() {
|
||||
return {
|
||||
@@ -50,7 +64,7 @@ export class UserService {
|
||||
const user = await this.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
|
||||
return this.createUser({
|
||||
@@ -139,10 +153,76 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
id: string,
|
||||
data: Prisma.UserUpdateInput,
|
||||
select: Prisma.UserSelect = this.defaultUserSelect
|
||||
) {
|
||||
const user = await this.prisma.user.update({ where: { id }, data, select });
|
||||
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
const user = await this.prisma.user.delete({ where: { id } });
|
||||
this.emitter.emit('user.deleted', user);
|
||||
}
|
||||
|
||||
@OnEvent('user.updated')
|
||||
async onUserUpdated(user: EventPayload<'user.deleted'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
const payload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
created_at: Number(user.createdAt) / 1000,
|
||||
};
|
||||
try {
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user update event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
try {
|
||||
if (user.emailVerifiedAt) {
|
||||
// suppress email if email is verified
|
||||
await fetch(
|
||||
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${customerIo.token}` },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user delete event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,49 +6,9 @@ import {
|
||||
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' })
|
||||
name!: string;
|
||||
|
||||
@Field({ name: 'blobLimit' })
|
||||
blobLimit!: string;
|
||||
|
||||
@Field({ name: 'storageQuota' })
|
||||
storageQuota!: string;
|
||||
|
||||
@Field({ name: 'historyPeriod' })
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType('UserQuota')
|
||||
export class UserQuotaType {
|
||||
@Field({ name: 'name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'blobLimit' })
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'storageQuota' })
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field({ name: 'memberLimit' })
|
||||
memberLimit!: number;
|
||||
|
||||
@Field({ name: 'humanReadable' })
|
||||
humanReadable!: UserQuotaHumanReadableType;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements CurrentUser {
|
||||
@Field(() => ID)
|
||||
|
||||
@@ -1,56 +1,26 @@
|
||||
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);
|
||||
import { InvalidEmail, InvalidPasswordLength } from '../../fundamentals';
|
||||
|
||||
export function assertValidEmail(email: string) {
|
||||
const result = z.string().email().safeParse(email);
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues.at(0);
|
||||
if (firstIssue) {
|
||||
throw new BadRequestException(firstIssue.message);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid credential');
|
||||
}
|
||||
throw new InvalidEmail();
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidEmail(email: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.email, email);
|
||||
}
|
||||
export function assertValidPassword(
|
||||
password: string,
|
||||
{ min, max }: { min: number; max: number }
|
||||
) {
|
||||
const result = z.string().min(min).max(max).safeParse(password);
|
||||
|
||||
export function assertValidPassword(password: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.password, password);
|
||||
}
|
||||
|
||||
export function assertValidCredential(credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
assertValid(getAuthCredentialValidator(), credential);
|
||||
if (!result.success) {
|
||||
throw new InvalidPasswordLength({ min, max });
|
||||
}
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
assertValidEmail,
|
||||
assertValidPassword,
|
||||
assertValidCredential,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Logger, Param, Res } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { CallTimer } from '../../fundamentals';
|
||||
import {
|
||||
AccessDenied,
|
||||
ActionForbidden,
|
||||
BlobNotFound,
|
||||
CallTimer,
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
InvalidHistoryTimestamp,
|
||||
} from '../../fundamentals';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { DocHistoryManager, DocManager } from '../doc';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
@@ -50,15 +50,16 @@ export class WorkspacesController {
|
||||
user?.id
|
||||
))
|
||||
) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const { body, metadata } = await this.storage.get(workspaceId, name);
|
||||
|
||||
if (!body) {
|
||||
throw new NotFoundException(
|
||||
`Blob not found in workspace ${workspaceId}: ${name}`
|
||||
);
|
||||
throw new BlobNotFound({
|
||||
workspaceId,
|
||||
blobId: name,
|
||||
});
|
||||
}
|
||||
|
||||
// metadata should always exists if body is not null
|
||||
@@ -93,7 +94,7 @@ export class WorkspacesController {
|
||||
user?.id
|
||||
))
|
||||
) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
throw new AccessDenied();
|
||||
}
|
||||
|
||||
const binResponse = await this.docManager.getBinary(
|
||||
@@ -102,7 +103,10 @@ export class WorkspacesController {
|
||||
);
|
||||
|
||||
if (!binResponse) {
|
||||
throw new NotFoundException('Doc not found');
|
||||
throw new DocNotFound({
|
||||
workspaceId: docId.workspace,
|
||||
docId: docId.guid,
|
||||
});
|
||||
}
|
||||
|
||||
if (!docId.isWorkspace) {
|
||||
@@ -139,7 +143,7 @@ export class WorkspacesController {
|
||||
try {
|
||||
ts = new Date(timestamp);
|
||||
} catch (e) {
|
||||
throw new Error('Invalid timestamp');
|
||||
throw new InvalidHistoryTimestamp({ timestamp });
|
||||
}
|
||||
|
||||
await this.permission.checkPagePermission(
|
||||
@@ -160,7 +164,11 @@ export class WorkspacesController {
|
||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||
res.send(history.blob);
|
||||
} else {
|
||||
throw new NotFoundException('Doc history not found');
|
||||
throw new DocHistoryNotFound({
|
||||
workspaceId: docId.workspace,
|
||||
docId: guid,
|
||||
timestamp: ts.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -9,7 +8,9 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { Admin } from '../common';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceType } from './types';
|
||||
@@ -21,41 +22,29 @@ export class WorkspaceManagementResolver {
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async addWorkspaceFeature(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.addWorkspaceFeatures(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Mutation(() => Int)
|
||||
async removeWorkspaceFeature(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<boolean> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Query(() => [WorkspaceType])
|
||||
async listWorkspaceFeatures(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<WorkspaceType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
|
||||
@@ -67,13 +56,13 @@ export class WorkspaceManagementResolver {
|
||||
@Args('enable') enable: boolean
|
||||
): Promise<boolean> {
|
||||
if (!(await this.feature.canEarlyAccess(user.email))) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
||||
const availableFeatures = await this.availableFeatures(user);
|
||||
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { DocAccessDenied, WorkspaceAccessDenied } from '../../fundamentals';
|
||||
import { Permission } from './types';
|
||||
|
||||
export enum PublicPageMode {
|
||||
@@ -151,7 +152,7 @@ export class PermissionService {
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
if (!(await this.tryCheckWorkspace(ws, user, permission))) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
throw new WorkspaceAccessDenied({ workspaceId: ws });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +324,7 @@ export class PermissionService {
|
||||
permission = Permission.Read
|
||||
) {
|
||||
if (!(await this.tryCheckPage(ws, page, user, permission))) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
throw new DocAccessDenied({ workspaceId: ws, docId: page });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Logger, PayloadTooLargeException, UseGuards } from '@nestjs/common';
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -13,6 +13,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import type { FileUpload } from '../../../fundamentals';
|
||||
import {
|
||||
BlobQuotaExceeded,
|
||||
CloudThrottlerGuard,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
@@ -126,10 +127,9 @@ export class WorkspaceBlobResolver {
|
||||
const checkExceeded =
|
||||
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
|
||||
|
||||
// TODO(@darksky): need a proper way to separate `BlobQuotaExceeded` and `BlobSizeTooLarge`
|
||||
if (checkExceeded(0)) {
|
||||
throw new PayloadTooLargeException(
|
||||
'Storage or blob size limit exceeded.'
|
||||
);
|
||||
throw new BlobQuotaExceeded();
|
||||
}
|
||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const stream = blob.createReadStream();
|
||||
@@ -140,9 +140,7 @@ export class WorkspaceBlobResolver {
|
||||
// check size after receive each chunk to avoid unnecessary memory usage
|
||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||
if (checkExceeded(bufferSize)) {
|
||||
reject(
|
||||
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
||||
);
|
||||
reject(new BlobQuotaExceeded());
|
||||
}
|
||||
});
|
||||
stream.on('error', reject);
|
||||
@@ -150,7 +148,7 @@ export class WorkspaceBlobResolver {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (checkExceeded(buffer.length)) {
|
||||
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
||||
reject(new BlobQuotaExceeded());
|
||||
} else {
|
||||
resolve(buffer);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,6 @@ export class DocHistoryResolver {
|
||||
): Promise<DocHistoryType[]> {
|
||||
const docId = new DocID(guid, workspace.id);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new Error('Invalid guid for listing doc histories.');
|
||||
}
|
||||
|
||||
return this.historyManager
|
||||
.list(workspace.id, docId.guid, timestamp, take)
|
||||
.then(rows =>
|
||||
@@ -73,10 +69,6 @@ export class DocHistoryResolver {
|
||||
): Promise<Date> {
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new Error('Invalid guid for recovering doc from history.');
|
||||
}
|
||||
|
||||
await this.permission.checkPagePermission(
|
||||
docId.workspace,
|
||||
docId.guid,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -12,6 +11,11 @@ import {
|
||||
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
ExpectToPublishPage,
|
||||
ExpectToRevokePublicPage,
|
||||
PageIsNotPublic,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService, PublicPageMode } from '../permission';
|
||||
@@ -126,7 +130,7 @@ export class PagePermissionResolver {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
throw new ExpectToPublishPage();
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -163,7 +167,7 @@ export class PagePermissionResolver {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
throw new ExpectToRevokePublicPage('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -178,7 +182,7 @@ export class PagePermissionResolver {
|
||||
);
|
||||
|
||||
if (!isPublic) {
|
||||
throw new BadRequestException('Page is not public');
|
||||
throw new PageIsNotPublic('Page is not public');
|
||||
}
|
||||
|
||||
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
} from '@nestjs/common';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -21,11 +15,18 @@ import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
import type { FileUpload } from '../../../fundamentals';
|
||||
import {
|
||||
CantChangeWorkspaceOwner,
|
||||
EventEmitter,
|
||||
InternalServerError,
|
||||
MailService,
|
||||
MemberQuotaExceeded,
|
||||
MutexService,
|
||||
Throttle,
|
||||
TooManyRequestsException,
|
||||
TooManyRequest,
|
||||
UserNotFound,
|
||||
WorkspaceAccessDenied,
|
||||
WorkspaceNotFound,
|
||||
WorkspaceOwnerNotFound,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
@@ -77,7 +78,7 @@ export class WorkspaceResolver {
|
||||
const permission = await this.permissions.get(workspace.id, user.id);
|
||||
|
||||
if (!permission) {
|
||||
throw new ForbiddenException();
|
||||
throw new WorkspaceAccessDenied({ workspaceId: workspace.id });
|
||||
}
|
||||
|
||||
return permission;
|
||||
@@ -196,7 +197,7 @@ export class WorkspaceResolver {
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
throw new WorkspaceNotFound({ workspaceId: id });
|
||||
}
|
||||
|
||||
return workspace;
|
||||
@@ -307,7 +308,7 @@ export class WorkspaceResolver {
|
||||
);
|
||||
|
||||
if (permission === Permission.Owner) {
|
||||
throw new ForbiddenException('Cannot change owner');
|
||||
throw new CantChangeWorkspaceOwner();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -315,7 +316,7 @@ export class WorkspaceResolver {
|
||||
const lockFlag = `invite:${workspaceId}`;
|
||||
await using lock = await this.mutex.lock(lockFlag);
|
||||
if (!lock) {
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
return new TooManyRequest();
|
||||
}
|
||||
|
||||
// member limit check
|
||||
@@ -326,7 +327,7 @@ export class WorkspaceResolver {
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
return new PayloadTooLargeException('Workspace member limit reached.');
|
||||
return new MemberQuotaExceeded();
|
||||
}
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
@@ -381,7 +382,7 @@ export class WorkspaceResolver {
|
||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||
);
|
||||
}
|
||||
return new InternalServerErrorException(
|
||||
throw new InternalServerError(
|
||||
'Failed to send invite email. Please try again.'
|
||||
);
|
||||
}
|
||||
@@ -389,7 +390,7 @@ export class WorkspaceResolver {
|
||||
return inviteId;
|
||||
} catch (e) {
|
||||
this.logger.error('failed to invite user', e);
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
return new TooManyRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,9 +482,7 @@ export class WorkspaceResolver {
|
||||
} = await this.getInviteInfo(inviteId);
|
||||
|
||||
if (!inviter || !invitee) {
|
||||
throw new ForbiddenException(
|
||||
`can not find inviter/invitee by inviteId: ${inviteId}`
|
||||
);
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
if (sendAcceptMail) {
|
||||
@@ -508,9 +507,7 @@ export class WorkspaceResolver {
|
||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
|
||||
if (!owner.user) {
|
||||
throw new ForbiddenException(
|
||||
`can not find owner by workspaceId: ${workspaceId}`
|
||||
);
|
||||
throw new WorkspaceOwnerNotFound({ workspaceId: workspaceId });
|
||||
}
|
||||
|
||||
if (sendLeaveMail) {
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AppModule as BusinessAppModule } from '../app.module';
|
||||
import { ConfigModule } from '../fundamentals/config';
|
||||
import { CreateCommand, NameQuestion } from './commands/create';
|
||||
import { RevertCommand, RunCommand } from './commands/run';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
doc: {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: false,
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
BusinessAppModule,
|
||||
],
|
||||
imports: [BusinessAppModule],
|
||||
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
|
||||
})
|
||||
export class CliAppModule {}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Logger } from '@nestjs/common';
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
|
||||
async function bootstrap() {
|
||||
AFFiNE.metrics.enabled = false;
|
||||
AFFiNE.doc.manager.enableUpdateAutoMerging = false;
|
||||
const { CliAppModule } = await import('./app');
|
||||
await CommandFactory.run(CliAppModule, new Logger()).catch(e => {
|
||||
console.error(e);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Features } from '../../core/features';
|
||||
import { Quotas } from '../../core/quota/schema';
|
||||
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
|
||||
import { upsertFeature } from './utils/user-features';
|
||||
|
||||
export class UserFeaturesInit1698652531198 {
|
||||
// do the migration
|
||||
@@ -11,7 +11,6 @@ export class UserFeaturesInit1698652531198 {
|
||||
for (const feature of Features) {
|
||||
await upsertFeature(db, feature);
|
||||
}
|
||||
await migrateNewFeatureTable(db);
|
||||
|
||||
for (const quota of Quotas) {
|
||||
await upsertFeature(db, quota);
|
||||
@@ -20,6 +19,6 @@ export class UserFeaturesInit1698652531198 {
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {
|
||||
// TODO: revert the migration
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user