mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-06 01:23:46 +00:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd56d8a6e6 | ||
|
|
6ed5ec36bb | ||
|
|
0fa917aabb | ||
|
|
eb79e6bdc9 | ||
|
|
cf52a43773 | ||
|
|
9203980a8c | ||
|
|
d34eb2cbe5 | ||
|
|
7f3f993ce4 | ||
|
|
df17001284 | ||
|
|
e400abf1f4 | ||
|
|
640aa00148 | ||
|
|
5ae8f029f7 | ||
|
|
a26e0b3ec9 | ||
|
|
f492b6711b | ||
|
|
81aae61394 | ||
|
|
e08f58beea | ||
|
|
4560819f76 | ||
|
|
193c197a54 | ||
|
|
449c0a38a7 | ||
|
|
8d141e5a81 | ||
|
|
e04911315f | ||
|
|
75d58679b6 | ||
|
|
2e6386e4cf | ||
|
|
f345a61df0 | ||
|
|
a6420fcd76 | ||
|
|
fec406f7e8 | ||
|
|
769398591b | ||
|
|
e01569fff7 | ||
|
|
6bde2de783 | ||
|
|
3513ced6cb | ||
|
|
8dc9addc40 | ||
|
|
9d9f89ef2e | ||
|
|
6cfe5d4566 | ||
|
|
6032b432f8 | ||
|
|
5823787ded | ||
|
|
b3f272ba70 | ||
|
|
a5df5a7c8a | ||
|
|
90de90403a | ||
|
|
4d4e4fc4e2 | ||
|
|
aa73e532d3 | ||
|
|
31faa93c71 | ||
|
|
def60f4c61 | ||
|
|
d15ec0ff77 | ||
|
|
d2acd0385a | ||
|
|
1effb2f25f | ||
|
|
9189d26332 | ||
|
|
79a8be7799 | ||
|
|
1a643cc70c | ||
|
|
9321be3ff5 | ||
|
|
24dc3f95ff | ||
|
|
4257b5f3a4 | ||
|
|
ea17e86032 | ||
|
|
48cd8999bd | ||
|
|
cdf1d9002e | ||
|
|
79b39f14d2 | ||
|
|
619420cfd1 | ||
|
|
739e914b5f | ||
|
|
5e9739eb3a | ||
|
|
0a89b7f528 | ||
|
|
0a0ee37ac2 | ||
|
|
a143379161 | ||
|
|
8e7dedfe82 | ||
|
|
d25a8547d0 | ||
|
|
4d16229fea | ||
|
|
99371be7e8 | ||
|
|
34ed8dd7a5 | ||
|
|
39b7b671b1 | ||
|
|
207b56d5af | ||
|
|
9e94e7195b | ||
|
|
de951c8779 | ||
|
|
fd37026ca5 | ||
|
|
4fd5812a89 | ||
|
|
d01e987ecc | ||
|
|
d87c218c0b | ||
|
|
a5bf5cc244 | ||
|
|
16bcd6e76b | ||
|
|
2e2ace8472 | ||
|
|
37cff8fe8d | ||
|
|
70ab3b4916 | ||
|
|
f42ba54578 | ||
|
|
a67c8181fc | ||
|
|
613efbded9 | ||
|
|
549419d102 | ||
|
|
21c42f8771 | ||
|
|
9012adda7a | ||
|
|
fb442e9055 | ||
|
|
a231474dd2 | ||
|
|
833b42000b | ||
|
|
7690c48710 | ||
|
|
579828a700 | ||
|
|
746db2ccfc | ||
|
|
eff344a9c1 | ||
|
|
c89ebab596 | ||
|
|
62f4421b7c | ||
|
|
42383dbd29 | ||
|
|
120e7397ba | ||
|
|
24123ad01c | ||
|
|
ad50320391 | ||
|
|
eb21a60dda | ||
|
|
c0e3be2d40 | ||
|
|
09d3b72358 | ||
|
|
246e16c6c0 | ||
|
|
dc279d062b | ||
|
|
47d5f9e1c2 | ||
|
|
a226eb8d5f | ||
|
|
908c4e1a6f | ||
|
|
1d0bcc80a0 | ||
|
|
50010bd824 | ||
|
|
c0ede1326d | ||
|
|
89197bacef | ||
|
|
f97d323ab5 | ||
|
|
2acb219dcc | ||
|
|
992ed89a89 | ||
|
|
d272d7922d | ||
|
|
c1cd1713b9 | ||
|
|
b20e91bee0 | ||
|
|
9a4e5ec8c3 | ||
|
|
2019838ae7 | ||
|
|
30ff25f400 | ||
|
|
e766208c18 | ||
|
|
8742f28148 | ||
|
|
cd291bb60e | ||
|
|
62c0efcfd1 | ||
|
|
87248b3337 | ||
|
|
00c940f7df | ||
|
|
931b459fbd | ||
|
|
51e71f4a0a | ||
|
|
9b631f2328 | ||
|
|
01f481a9b6 | ||
|
|
0177ab5c87 | ||
|
|
4db35d341c | ||
|
|
3c4a803c97 | ||
|
|
05154dc7ca | ||
|
|
c90b477f60 | ||
|
|
6f18ddbe85 | ||
|
|
dde779a71d | ||
|
|
bd9f66fbc7 | ||
|
|
92f1f40bfa | ||
|
|
48dc1049b3 | ||
|
|
9add530370 | ||
|
|
b77460d871 | ||
|
|
42db41776b | ||
|
|
075439c74f | ||
|
|
fc6c553ece | ||
|
|
59cb3d5df1 |
@@ -8,11 +8,5 @@ corepack prepare yarn@stable --activate
|
||||
# install dependencies
|
||||
yarn install
|
||||
|
||||
# Build Server Dependencies
|
||||
yarn workspace @affine/storage build
|
||||
|
||||
# Create database
|
||||
yarn workspace @affine/server prisma db push
|
||||
|
||||
# Create user username: affine, password: affine
|
||||
echo "INSERT INTO \"users\"(\"id\",\"name\",\"email\",\"email_verified\",\"created_at\",\"password\") VALUES('99f3ad04-7c9b-441e-a6db-79f73aa64db9','affine','affine@affine.pro','2024-02-26 15:54:16.974','2024-02-26 15:54:16.974+00','\$argon2id\$v=19\$m=19456,t=2,p=1\$esDS3QCHRH0Kmeh87YPm5Q\$9S+jf+xzw2Hicj6nkWltvaaaXX3dQIxAFwCfFa9o38A');" | yarn workspace @affine/server prisma db execute --stdin
|
||||
yarn workspace @affine/server prisma db push
|
||||
@@ -21,6 +21,5 @@
|
||||
}
|
||||
},
|
||||
"updateContentCommand": "bash ./.devcontainer/build.sh",
|
||||
"postCreateCommand": "bash ./.devcontainer/setup-user.sh",
|
||||
"postStartCommand": ["yarn dev", "yarn workspace @affine/server dev"]
|
||||
"postCreateCommand": "bash ./.devcontainer/setup-user.sh"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
set -e
|
||||
|
||||
if [ -v GRAPHITE_TOKEN ];then
|
||||
gt auth --token $GRAPHITE_TOKEN
|
||||
fi
|
||||
|
||||
git fetch origin canary:canary --depth=1
|
||||
git fetch
|
||||
git branch canary -t origin/canary
|
||||
gt init --trunk canary
|
||||
|
||||
24
.eslintrc.js
24
.eslintrc.js
@@ -31,6 +31,17 @@ const createPattern = packageName => [
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'use-current-user.tsx'",
|
||||
// useSession is type unsafe
|
||||
importNames: ['useSession'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'cloud-utils.ts'",
|
||||
importNames: ['signIn', 'signOut'],
|
||||
},
|
||||
{
|
||||
group: ['yjs'],
|
||||
message: 'Do not use this API because it has a bug',
|
||||
@@ -53,7 +64,7 @@ const allPackages = [
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/native',
|
||||
'packages/frontend/templates',
|
||||
'packages/frontend/workspace-impl',
|
||||
'packages/frontend/workspace',
|
||||
'packages/common/debug',
|
||||
'packages/common/env',
|
||||
'packages/common/infra',
|
||||
@@ -168,6 +179,17 @@ const config = {
|
||||
message: 'Use `useNavigateHelper` instead',
|
||||
importNames: ['useNavigate'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'use-current-user.tsx'",
|
||||
// useSession is type unsafe
|
||||
importNames: ['useSession'],
|
||||
},
|
||||
{
|
||||
group: ['next-auth/react'],
|
||||
message: "Import hooks from 'cloud-utils.ts'",
|
||||
importNames: ['signIn', 'signOut'],
|
||||
},
|
||||
{
|
||||
group: ['yjs'],
|
||||
message: 'Do not use this API because it has a bug',
|
||||
|
||||
18
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
18
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
@@ -7,8 +7,6 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
Check out this [link](https://github.com/toeverything/AFFiNE/blob/canary/docs/issue-triaging.md)
|
||||
to learn how we manage issues and when your issue will be processed.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -43,14 +41,6 @@ body:
|
||||
- Firefox
|
||||
- Safari
|
||||
- Other
|
||||
- type: checkboxes
|
||||
id: selfhost
|
||||
attributes:
|
||||
label: Are you self-hosting?
|
||||
description: >
|
||||
If you are self-hosting, please check the box and provide information about your setup.
|
||||
options:
|
||||
- label: 'Yes'
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
@@ -63,3 +53,11 @@ body:
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
Tip: You can attach images here
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Are you willing to submit a PR?
|
||||
description: >
|
||||
(Optional) We encourage you to submit a [Pull Request](https://github.com/toeverything/affine/pulls) (PR) to help improve AFFiNE for everyone, especially if you have a good understanding of how to implement a fix or feature.
|
||||
See the AFFiNE [Contributing Guide](https://github.com/toeverything/affine/blob/canary/CONTRIBUTING.md) to get started.
|
||||
options:
|
||||
- label: Yes I'd like to help by submitting a PR!
|
||||
|
||||
2
.github/actions/build-rust/action.yml
vendored
2
.github/actions/build-rust/action.yml
vendored
@@ -49,7 +49,7 @@ runs:
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} -- --target ${{ inputs.target }} --use-napi-cross
|
||||
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }} --use-napi-cross
|
||||
env:
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ inputs.nx_token }}
|
||||
DEBUG: 'napi:*'
|
||||
|
||||
2
.github/actions/deploy/action.yml
vendored
2
.github/actions/deploy/action.yml
vendored
@@ -24,7 +24,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
- uses: azure/setup-helm@v4
|
||||
- uses: azure/setup-helm@v3
|
||||
- id: auth
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
|
||||
6
.github/actions/deploy/deploy.mjs
vendored
6
.github/actions/deploy/deploy.mjs
vendored
@@ -21,7 +21,6 @@ const {
|
||||
AFFINE_GOOGLE_CLIENT_ID,
|
||||
AFFINE_GOOGLE_CLIENT_SECRET,
|
||||
CLOUD_SQL_IAM_ACCOUNT,
|
||||
CLOUD_LOGGER_IAM_ACCOUNT,
|
||||
GCLOUD_CONNECTION_NAME,
|
||||
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
|
||||
REDIS_HOST,
|
||||
@@ -60,9 +59,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
? [
|
||||
`--set-json web.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
|
||||
`--set-json graphql.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
|
||||
`--set-json graphql.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
|
||||
`--set-json sync.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
|
||||
`--set-json sync.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_LOGGER_IAM_ACCOUNT}\\"}\"`,
|
||||
`--set-json cloud-sql-proxy.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`,
|
||||
`--set-json cloud-sql-proxy.nodeSelector=\"{ \\"iam.gke.io/gke-metadata-server-enabled\\": \\"true\\" }\"`,
|
||||
]
|
||||
@@ -114,9 +111,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.experimental.enableJwstCodec=${isInternal}`,
|
||||
`--set graphql.app.experimental.enableJwstCodec=true`,
|
||||
`--set graphql.app.features.earlyAccessPreview=false`,
|
||||
`--set graphql.app.features.syncClientVersionCheck=true`,
|
||||
`--set sync.replicaCount=${syncReplicaCount}`,
|
||||
`--set-string sync.image.tag="${imageTag}"`,
|
||||
...serviceAnnotations,
|
||||
|
||||
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -1,4 +1,4 @@
|
||||
FROM openresty/openresty:1.25.3.1-0-buster
|
||||
FROM openresty/openresty:1.21.4.3-0-buster
|
||||
WORKDIR /app
|
||||
COPY ./packages/frontend/core/dist ./dist
|
||||
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||
|
||||
2
.github/deployment/node/Dockerfile
vendored
2
.github/deployment/node/Dockerfile
vendored
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bookworm-slim
|
||||
FROM node:18-bookworm-slim
|
||||
|
||||
COPY ./packages/backend/server /app
|
||||
COPY ./packages/frontend/core/dist /app/static
|
||||
|
||||
@@ -60,7 +60,6 @@ app:
|
||||
webhookKey: ''
|
||||
features:
|
||||
earlyAccessPreview: false
|
||||
syncClientVersionCheck: false
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
|
||||
7
.github/helm/affine/templates/ingress.yaml
vendored
7
.github/helm/affine/templates/ingress.yaml
vendored
@@ -60,13 +60,6 @@ spec:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /oauth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -29,6 +29,11 @@ mod:plugin-cli:
|
||||
- any-glob-to-any-file:
|
||||
- 'tools/plugin-cli/**/*'
|
||||
|
||||
mod:workspace:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/common/workspace/**/*'
|
||||
|
||||
mod:workspace-impl:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
7
.github/renovate.json
vendored
7
.github/renovate.json
vendored
@@ -46,11 +46,6 @@
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "electron-forge"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["oxlint"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "oxlint"
|
||||
},
|
||||
{
|
||||
"groupName": "blocksuite-canary",
|
||||
"matchPackagePatterns": ["^@blocksuite"],
|
||||
@@ -62,7 +57,7 @@
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"excludePackagePatterns": ["^@blocksuite/", "oxlint"],
|
||||
"excludePackagePatterns": ["^@blocksuite/"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
|
||||
70
.github/workflows/build-test.yml
vendored
70
.github/workflows/build-test.yml
vendored
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
- v[0-9]+.[0-9]+.x-staging
|
||||
- v[0-9]+.[0-9]+.x
|
||||
paths-ignore:
|
||||
@@ -118,7 +116,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISTRIBUTION: browser
|
||||
IN_CI_TEST: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -193,7 +190,7 @@ jobs:
|
||||
run: yarn nx test:coverage @affine/monorepo
|
||||
|
||||
- name: Upload unit test coverage results
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./.coverage/store/lcov.info
|
||||
@@ -212,8 +209,8 @@ jobs:
|
||||
spec:
|
||||
- { os: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { os: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
- { os: macos-14, target: x86_64-apple-darwin }
|
||||
- { os: macos-14, target: aarch64-apple-darwin }
|
||||
- { os: macos-latest, target: x86_64-apple-darwin }
|
||||
- { os: macos-latest, target: aarch64-apple-darwin }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -336,11 +333,17 @@ jobs:
|
||||
env:
|
||||
PGPASSWORD: affine
|
||||
|
||||
- name: Run init-db script
|
||||
- name: Generate prisma client
|
||||
run: |
|
||||
yarn workspace @affine/server exec prisma generate
|
||||
yarn workspace @affine/server exec prisma db push
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server data-migration run
|
||||
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
@@ -351,7 +354,7 @@ jobs:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/server/.coverage/lcov.info
|
||||
@@ -365,7 +368,6 @@ jobs:
|
||||
env:
|
||||
DISTRIBUTION: browser
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
IN_CI_TEST: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -429,11 +431,17 @@ jobs:
|
||||
env:
|
||||
PGPASSWORD: affine
|
||||
|
||||
- name: Run init-db script
|
||||
- name: Generate prisma client
|
||||
run: |
|
||||
yarn workspace @affine/server exec prisma generate
|
||||
yarn workspace @affine/server exec prisma db push
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Run init-db script
|
||||
run: |
|
||||
yarn workspace @affine/server data-migration run
|
||||
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
|
||||
|
||||
- name: ${{ matrix.tests.name }}
|
||||
run: |
|
||||
@@ -454,21 +462,22 @@ jobs:
|
||||
runs-on: ${{ matrix.spec.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# all combinations: macos-latest x64, macos-latest arm64, windows-latest x64, ubuntu-latest x64
|
||||
matrix:
|
||||
spec:
|
||||
- {
|
||||
os: macos-14,
|
||||
os: macos-latest,
|
||||
platform: macos,
|
||||
arch: x64,
|
||||
target: x86_64-apple-darwin,
|
||||
test: false,
|
||||
test: true,
|
||||
}
|
||||
- {
|
||||
os: macos-14,
|
||||
os: macos-latest,
|
||||
platform: macos,
|
||||
arch: arm64,
|
||||
target: aarch64-apple-darwin,
|
||||
test: true,
|
||||
test: false,
|
||||
}
|
||||
- {
|
||||
os: ubuntu-latest,
|
||||
@@ -525,7 +534,7 @@ jobs:
|
||||
run: yarn workspace @affine/electron build
|
||||
|
||||
- name: Run desktop tests
|
||||
if: ${{ matrix.spec.os == 'ubuntu-latest' }}
|
||||
if: ${{ matrix.spec.test && matrix.spec.os == 'ubuntu-latest' }}
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn workspace @affine-test/affine-desktop e2e
|
||||
|
||||
- name: Run desktop tests
|
||||
@@ -533,23 +542,15 @@ jobs:
|
||||
run: yarn workspace @affine-test/affine-desktop e2e
|
||||
|
||||
- name: Make bundle
|
||||
if: ${{ matrix.spec.target == 'aarch64-apple-darwin' }}
|
||||
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
|
||||
env:
|
||||
SKIP_BUNDLE: true
|
||||
SKIP_WEB_BUILD: true
|
||||
HOIST_NODE_MODULES: 1
|
||||
run: yarn workspace @affine/electron package --platform=darwin --arch=arm64
|
||||
|
||||
- name: Make AppImage
|
||||
run: yarn workspace @affine/electron make --platform=linux --arch=x64
|
||||
if: ${{ matrix.spec.target == 'x86_64-unknown-linux-gnu' }}
|
||||
env:
|
||||
SKIP_PLUGIN_BUILD: 1
|
||||
SKIP_WEB_BUILD: 1
|
||||
HOIST_NODE_MODULES: 1
|
||||
|
||||
- name: Output check
|
||||
if: ${{ matrix.spec.os == 'macos-14' && matrix.spec.arch == 'arm64' }}
|
||||
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
|
||||
run: |
|
||||
yarn workspace @affine/electron exec node --loader ts-node/esm/transpile-only ./scripts/macos-arm64-output-check.ts
|
||||
|
||||
@@ -560,22 +561,3 @@ jobs:
|
||||
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
test-done:
|
||||
needs:
|
||||
- analyze
|
||||
- lint
|
||||
- check-yarn-binary
|
||||
- e2e-test
|
||||
- e2e-migration-test
|
||||
- unit-test
|
||||
- server-test
|
||||
- server-e2e-test
|
||||
- desktop-test
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
name: 3, 2, 1 Launch
|
||||
steps:
|
||||
- run: exit 1
|
||||
# Thank you, next https://github.com/vercel/next.js/blob/canary/.github/workflows/build_and_test.yml#L379
|
||||
if: ${{ always() && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }}
|
||||
|
||||
5
.github/workflows/deploy-automatically.yml
vendored
5
.github/workflows/deploy-automatically.yml
vendored
@@ -7,11 +7,6 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
dispatch-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
build-core-selfhost:
|
||||
name: Build @affine/core selfhost
|
||||
name: Build @affine/core
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
@@ -88,7 +88,6 @@ jobs:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: false
|
||||
PUBLIC_PATH: '/'
|
||||
SELF_HOSTED: true
|
||||
- name: Download selfhost fonts
|
||||
run: node ./scripts/download-blocksuite-fonts.mjs
|
||||
- name: Upload core artifact
|
||||
@@ -137,10 +136,6 @@ jobs:
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: 'write'
|
||||
id-token: 'write'
|
||||
packages: 'write'
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
@@ -295,7 +290,6 @@ jobs:
|
||||
REDIS_HOST: ${{ secrets.REDIS_HOST }}
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
|
||||
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}
|
||||
CLOUD_LOGGER_IAM_ACCOUNT: ${{ secrets.CLOUD_LOGGER_IAM_ACCOUNT }}
|
||||
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
|
||||
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
|
||||
STATIC_IP_NAME: ${{ secrets.STATIC_IP_NAME }}
|
||||
|
||||
2
.github/workflows/helm-releaser.yml
vendored
2
.github/workflows/helm-releaser.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
token: ${{ secrets.HELM_RELEASER_TOKEN }}
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
uses: azure/setup-helm@v3
|
||||
|
||||
- name: Install chart releaser
|
||||
run: |
|
||||
|
||||
2
.github/workflows/pr-auto-assign.yml
vendored
2
.github/workflows/pr-auto-assign.yml
vendored
@@ -9,4 +9,4 @@ jobs:
|
||||
add-reviews:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: kentaro-m/auto-assign-action@v2.0.0
|
||||
- uses: kentaro-m/auto-assign-action@v1.2.5
|
||||
|
||||
@@ -7,11 +7,6 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
dispatch-release-desktop:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
41
.github/workflows/release-desktop.yml
vendored
41
.github/workflows/release-desktop.yml
vendored
@@ -68,13 +68,15 @@ jobs:
|
||||
|
||||
make-distribution:
|
||||
strategy:
|
||||
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
|
||||
# For windows, we need a separate approach
|
||||
matrix:
|
||||
spec:
|
||||
- runner: macos-14
|
||||
- runner: macos-latest
|
||||
platform: darwin
|
||||
arch: x64
|
||||
target: x86_64-apple-darwin
|
||||
- runner: macos-14
|
||||
- runner: macos-latest
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
@@ -130,23 +132,18 @@ jobs:
|
||||
SKIP_WEB_BUILD: 1
|
||||
HOIST_NODE_MODULES: 1
|
||||
|
||||
- name: signing DMG
|
||||
if: ${{ matrix.spec.platform == 'darwin' }}
|
||||
run: |
|
||||
codesign --force --sign "Developer ID Application: TOEVERYTHING PTE. LTD." packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/AFFiNE.dmg
|
||||
|
||||
- name: Save artifacts (mac)
|
||||
if: ${{ matrix.spec.platform == 'darwin' }}
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/*.dmg ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
|
||||
mv packages/frontend/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
|
||||
mv packages/frontend/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
|
||||
mv packages/frontend/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
|
||||
- name: Save artifacts (linux)
|
||||
if: ${{ matrix.spec.platform == 'linux' }}
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-linux-x64.appimage
|
||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -156,6 +153,8 @@ jobs:
|
||||
|
||||
package-distribution-windows:
|
||||
strategy:
|
||||
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
|
||||
# For windows, we need a separate approach
|
||||
matrix:
|
||||
spec:
|
||||
- runner: windows-latest
|
||||
@@ -229,6 +228,8 @@ jobs:
|
||||
make-windows-installer:
|
||||
needs: sign-packaged-artifacts-windows
|
||||
strategy:
|
||||
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
|
||||
# For windows, we need a separate approach
|
||||
matrix:
|
||||
spec:
|
||||
- runner: windows-latest
|
||||
@@ -278,8 +279,10 @@ jobs:
|
||||
artifact-name: installer-win32-x64
|
||||
|
||||
finalize-installer-windows:
|
||||
needs: [sign-installer-artifacts-windows, before-make]
|
||||
needs: sign-installer-artifacts-windows
|
||||
strategy:
|
||||
# all combinations: macos-latest x64, macos-latest arm64, ubuntu-latest x64
|
||||
# For windows, we need a separate approach
|
||||
matrix:
|
||||
spec:
|
||||
- runner: windows-latest
|
||||
@@ -299,9 +302,9 @@ jobs:
|
||||
- name: Save artifacts
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.exe
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.msi
|
||||
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.exe
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.msi
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -351,7 +354,7 @@ jobs:
|
||||
RELEASE_VERSION: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
- name: Create Release Draft
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
body: ''
|
||||
@@ -362,12 +365,12 @@ jobs:
|
||||
./*.zip
|
||||
./*.dmg
|
||||
./*.exe
|
||||
./*.appimage
|
||||
./*.AppImage
|
||||
./*.apk
|
||||
./*.yml
|
||||
- name: Create Nightly Release Draft
|
||||
if: ${{ github.ref_type == 'branch' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
@@ -384,6 +387,6 @@ jobs:
|
||||
./*.zip
|
||||
./*.dmg
|
||||
./*.exe
|
||||
./*.appimage
|
||||
./*.AppImage
|
||||
./*.apk
|
||||
./*.yml
|
||||
|
||||
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.4.1
|
||||
uses: cloudflare/wrangler-action@v3.4.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
File diff suppressed because one or more lines are too long
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.1.1.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -949,9 +949,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
|
||||
130
README.md
130
README.md
@@ -5,89 +5,86 @@
|
||||
Write, Draw and Plan All at Once
|
||||
<br>
|
||||
</h1>
|
||||
<a href="https://affine.pro/download">
|
||||
<img alt="affine logo" src="https://cdn.affine.pro/Github_hero_image1.png" style="width: 100%">
|
||||
</a>
|
||||
<br/>
|
||||
<p align="center">
|
||||
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro. <br />
|
||||
One hyper-fused platform for wildly creative minds.
|
||||
|
||||
<p>
|
||||
One hyper-fused platform for wildly creative minds. <br />
|
||||
A privacy-focussed, local-first, open-source, and ready-to-use alternative for Notion & Miro.
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<br/>
|
||||
<a href="https://www.producthunt.com/posts/affine-3?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-affine-3" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=440671&theme=light" alt="AFFiNE - One app for all - Where Notion meets Miro | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://affine.pro">Home Page</a> |
|
||||
<a href="https://discord.com/invite/yz6tGVsf5p">Discord</a> |
|
||||
<a href="https://app.affine.pro">Live Demo</a> |
|
||||
<a href="https://affine.pro/blog/">Blog</a> |
|
||||
<a href="https://docs.affine.pro/docs/">Documentation</a>
|
||||
</div>
|
||||
<br/>
|
||||
|
||||
[?style=flat-square&logoColor=white&logo=affine>)](https://app.affine.pro)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
[](https://affine.pro/download)
|
||||
|
||||
[](https://github.com/toeverything/AFFiNE/releases/latest)
|
||||
[![stars-icon]](https://github.com/toeverything/AFFiNE)
|
||||
[![All Contributors][all-contributors-badge]](#contributors)
|
||||
[![codecov]](https://codecov.io/gh/toeverything/AFFiNE)
|
||||
[![Node-version-icon]](https://nodejs.org/)
|
||||
[![TypeScript-version-icon]](https://www.typescriptlang.org/)
|
||||
[![React-version-icon]](https://reactjs.org/)
|
||||
[![blocksuite-icon]](https://github.com/toeverything/blocksuite)
|
||||
[![Rust-version-icon]](https://www.rust-lang.org/)
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE?ref=badge_shield)
|
||||
[](https://github.com/toeverything/AFFiNE/actions/workflows/deploy.yml)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<a href="http://affine.pro"><img src="https://img.shields.io/badge/-AFFiNE-06449d?style=social&logo=affine" height=25></a>
|
||||
|
||||
<a href="https://community.affine.pro"><img src="https://img.shields.io/badge/-Community-424549?style=social&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAXNJREFUWEftlitLRUEURtdVEVExWUx2qxgNVouoXYtNDP4Tw20WtftAsItZrHaTYBJREZ98MAc248wcZxi4CGfSeezHmm/23kyPAa/egPPTAXQK/FsFBP7ldVDRZoqcgO9I+2bHy3ZIJBfTCPCZM1tqAxwBmzUBrNQNbEx+5b0B5oEN4NCBrAMnMaiUAuPAs3HU82TLEZwBqwGbaJ4UgKQ8CFR6SoEl4LIWwCJwZQCegKkWBWLHVKSActvdzgG3DqitDf3/VQBskBDALrDnAKXUo3ueAF5KinAf2DKOmnzD7l214bdbA6hC1XHZNQa8hSBC0hwDa57xDHDvvvWB7ciOZoE79+8CWPbsBGc769eFxJdWIKcuyIdRoG3W7AAC1dJkHDIOo8B78+4rEBo8r4AkLFk6Jk3HaeDBBTgHVmIAfpJUz+cAFXVBreQCvQYW/lqEjV1NAMUMqpAaxQMHyDnjYtuS+0BxstwaqJooFqxToFPgB5FuPCEB6XK2AAAAAElFTkSuQmCC" height=25></a>
|
||||
|
||||
<a href="https://discord.com/invite/yz6tGVsf5p"><img src="https://img.shields.io/badge/-Discord-424549?style=social&logo=discord" height=25></a>
|
||||
|
||||
<a href="https://t.me/affineworkos"><img src="https://img.shields.io/badge/-Telegram-red?style=social&logo=telegram" height=25></a>
|
||||
|
||||
<a href="https://twitter.com/AffineOfficial"><img src="https://img.shields.io/badge/-Twitter-red?style=social&logo=twitter" height=25></a>
|
||||
|
||||
<a href="https://medium.com/@affineworkos"><img src="https://img.shields.io/badge/-Medium-red?style=social&logo=medium" height=25></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div align="center">
|
||||
<em>Docs, canvas and tables are hyper-merged with AFFiNE - just like the word affine (əˈfʌɪn | a-fine).</em>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<img src="https://github.com/toeverything/AFFiNE/assets/79301703/49a426bb-8d2b-4216-891a-fa5993642253" style="width: 100%"/>
|
||||
</div>
|
||||

|
||||
|
||||
## Join our community
|
||||
|
||||
Before we tell you how to get started with AFFiNE, we'd like to shamelessly plug our awesome user and developer communities across [official social platforms](https://community.affine.pro/c/start-here/)! Once you’re familiar with using the software, maybe you will share your wisdom with others and even consider joining the [AFFiNE Ambassador program](https://community.affine.pro/c/start-here/affine-ambassador) to help spread AFFiNE to the world.
|
||||
|
||||
## Getting started & staying tuned with us.
|
||||
|
||||
⚠️ Please note that AFFiNE is still under active development and is not yet ready for production use. ⚠️
|
||||
|
||||
[](https://app.affine.pro) No installation or registration required! Head over to our website and try it out now.
|
||||
|
||||
[](https://community.affine.pro) Our wonderful community, where you can meet and engage with the team, developers and other like-minded enthusiastic user of AFFiNE.
|
||||
|
||||
Star us, and you will receive all releases notifications from GitHub without any delay!
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/79301703/230891830-0110681e-8c7e-483b-b6d9-9e42b291b9ef.gif" style="width: 100%"/>
|
||||
|
||||
## What is AFFiNE
|
||||
|
||||
AFFiNE is an open-source, all-in-one workspace and an operating system for all the building blocks that assemble your knowledge base and much more -- wiki, knowledge management, presentation and digital assets. It's a better alternative to Notion and Miro.
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
**A true canvas for blocks in any form. Docs and whiteboard are now fully merged.**
|
||||
- **Hyper merged** — Write, draw and plan all at once. Assemble any blocks you love on any canvas you like to enjoy seamless transitions between workflows with AFFiNE.
|
||||
- **Privacy focussed** — AFFiNE is built with your privacy in mind and is one of our key concerns. We want you to keep control of your data, allowing you to store it as you like, where you like while still being able to freely edit and view your data on-demand.
|
||||
- **Offline-first** — With your privacy in mind we also decided to go offline-first. This means that AFFiNE can be used offline, whether you want to view or edit, with support for conflict-free merging when you are back online.
|
||||
- **Clean, intuitive design** — With AFFiNE you can concentrate on editing with a clean and modern interface. Which is responsive, so it looks great on tablets too, and mobile support is coming in the future.
|
||||
- **Modern Block Editor with Markdown support** — A modern block editor can help you not only for docs, but slides and tables as well. When you write in AFFiNE you can use Markdown syntax which helps create an easier editing experience, that can be experienced with just a keyboard. And this allows you to export your data cleanly into Markdown.
|
||||
- **Collaboration** — Whether you want to collaborate with yourself across multiple devices, or work together with others, support for collaboration and multiplayer is out-of-the-box, which makes it easy for teams to get started with AFFiNE.
|
||||
- **Choice of multiple languages** — Thanks to community contributions AFFiNE offers support for multiple languages. If you don't find your language or would like to suggest some changes we welcome your contributions.
|
||||
|
||||
- Many editor apps claim to be a canvas for productivity, but AFFiNE is one of the very few which allows you to put any building block on an edgeless canvas -- rich text, sticky notes, any embedded web pages, multi-view databases, linked pages, shapes and even slides. We have it all.
|
||||
|
||||
**Multimodal AI partner ready to kick in any work**
|
||||
|
||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or....draw and code prototype apps and web pages directly all with one prompt? With you, AFFiNE AI pushes your creativity to the edge of your imagination.
|
||||
|
||||
**Local-first & Real-time collaborative**
|
||||
|
||||
- We love the idea of local-first that you always own your data on your disk, in spite of the cloud. Furthermore, AFFiNE supports real-time sync and collaborations on web and cross-platform clients.
|
||||
|
||||
**Self-host & Shape your own AFFiNE**
|
||||
|
||||
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks is coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
“We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us along the way, e.g.:
|
||||
|
||||
- Quip & Notion with their great concept of “everything is a block”
|
||||
- Trello with their Kanban
|
||||
- Airtable & Miro with their no-code programable datasheets
|
||||
- Miro & Whimiscal with their edgeless visual whiteboard
|
||||
- Remnote & Capacities with their object-based tag system
|
||||
|
||||
There is a large overlap of their atomic “building blocks” between these apps. They are not open source, nor do they have a plugin system like Vscode for contributors to customize. We want to have something that contains all the features we love and also goes one step even further.
|
||||
|
||||
Thanks for checking us out, we appreciate your interest and sincerely hope that AFFiNE resonates with you! 🎵 Checking https://affine.pro/ for more details ions.
|
||||

|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -120,7 +117,7 @@ If you have questions, you are welcome to contact us. One of the best places to
|
||||
|
||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||
|
||||
- [Blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
||||
- [blocksuite](https://github.com/toeverything/BlockSuite) - 💠 BlockSuite is the open-source collaborative editor project behind AFFiNE.
|
||||
- [OctoBase](https://github.com/toeverything/OctoBase) - 🐙 OctoBase is the open-source database behind AFFiNE, local-first, yet collaborative. A light-weight, scalable, data engine written in Rust.
|
||||
- [yjs](https://github.com/yjs/yjs) - Fundamental support of CRDTs for our implementation on state management and data sync.
|
||||
- [electron](https://github.com/electron/electron) - Build cross-platform desktop apps with JavaScript, HTML, and CSS.
|
||||
@@ -143,11 +140,20 @@ We would like to express our gratitude to all the individuals who have already c
|
||||
|
||||
## Self-Host
|
||||
|
||||
Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiNE. Our team is diligently updating to the latest version. For more information on how to self-host AFFiNE, please refer to our [documentation](https://docs.affine.pro/docs/self-host-affine-).
|
||||
> We know that the self-host version has been out of date for a long time.
|
||||
>
|
||||
> We are working hard to get this updated to the latest version, you can try our desktop version first.
|
||||
|
||||
Get started with Docker and deploy your own feature-rich, restriction-free deployment of AFFiNE.
|
||||
We are working hard to get this updated to the latest version, you can keep an eye on the [latest packages].
|
||||
|
||||
## Hiring
|
||||
|
||||
Some amazing companies including AFFiNE are looking for developers! Are you interesgo to iour discord channel AFFiNE and/or its partners? Check out some of the latest [jobs available].
|
||||
Some amazing companies including AFFiNE are looking for developers! Are you interested in helping build with AFFiNE and/or its partners? Check out some of the latest [jobs available].
|
||||
|
||||
## Upgrading
|
||||
|
||||
For upgrading information, please see our [update page].
|
||||
|
||||
## Feature Request
|
||||
|
||||
@@ -179,6 +185,8 @@ Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testi
|
||||
|
||||
See [LICENSE] for details.
|
||||
|
||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Ftoeverything%2FAFFiNE?ref=badge_large)
|
||||
|
||||
[all-contributors-badge]: https://img.shields.io/github/contributors/toeverything/AFFiNE
|
||||
[license]: ./LICENSE
|
||||
[building.md]: ./docs/BUILDING.md
|
||||
@@ -186,7 +194,7 @@ See [LICENSE] for details.
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.76.0-dea584
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.75.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
|
||||
|
||||
29
SECURITY.md
29
SECURITY.md
@@ -1,29 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We recommend users to always use the latest major version. Security updates will be provided for the current major version until the next major version is released.
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.12.x (stable) | :white_check_mark: |
|
||||
| < 0.12.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
|
||||
1. Using platform and version, for example:
|
||||
|
||||
- macos arm64 0.12.0-canary-202402220729-0868ac6
|
||||
- app.affine.pro 0.12.0-canary-202402220729-0868ac6
|
||||
|
||||
2. A sets of video or screenshot containing the reproduce steps that proves you successfully exploited the vulnerability, preferably including the time and software version of the successful exploit.
|
||||
|
||||
3. Your classification or analysis of the vulnerability (optional)
|
||||
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
|
||||
|
||||
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
|
||||
|
||||
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
||||
@@ -29,7 +29,7 @@ It includes the global constants, browser and system check.
|
||||
|
||||
This package should be imported at the very beginning of the entry point.
|
||||
|
||||
### `@affine/workspace-impl`
|
||||
### `@affine/workspace`
|
||||
|
||||
Current we have two workspace plugin:
|
||||
|
||||
|
||||
@@ -49,20 +49,22 @@ postgres=# \du
|
||||
|
||||
### Set the following config to `packages/backend/server/.env`
|
||||
|
||||
In the following setup, we assume you have postgres server running at localhost:5432 and mailhog running at localhost:1025.
|
||||
|
||||
When logging in via email, you will see the mail arriving at localhost:8025 in a browser.
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://affine:affine@localhost:5432/affine"
|
||||
NEXTAUTH_URL="http://localhost:8080"
|
||||
MAILER_SENDER="noreply@toeverything.info"
|
||||
MAILER_USER="auth"
|
||||
MAILER_PASSWORD="auth"
|
||||
MAILER_HOST="localhost"
|
||||
MAILER_PORT="1025"
|
||||
STRIPE_API_KEY=sk_live_1
|
||||
STRIPE_WEBHOOK_KEY=1
|
||||
NEXTAUTH_URL="http://localhost:8080/"
|
||||
```
|
||||
|
||||
You may need additional env for auth login. You may want to put your own one if you are not part of the AFFiNE team
|
||||
|
||||
For email login & password, please refer to https://nodemailer.com/usage/using-gmail/
|
||||
|
||||
```
|
||||
MAILER_SENDER=
|
||||
MAILER_USER=
|
||||
MAILER_PASSWORD=
|
||||
OAUTH_GOOGLE_ENABLED="true"
|
||||
OAUTH_GOOGLE_CLIENT_ID=
|
||||
OAUTH_GOOGLE_CLIENT_SECRET=
|
||||
```
|
||||
|
||||
## Prepare prisma
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
"dev": "nodemon --exec 'typedoc --options ../../typedoc.json' & serve dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"serve": "^14.2.1",
|
||||
"typedoc": "^0.25.8"
|
||||
"typedoc": "^0.25.4"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
|
||||
92
package.json
92
package.json
@@ -14,7 +14,7 @@
|
||||
"tests/affine-legacy/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": "<21.0.0"
|
||||
"node": ">=18.16.1 <19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "dev-core",
|
||||
@@ -36,7 +36,7 @@
|
||||
"test": "vitest --run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc -b tsconfig.json",
|
||||
"typecheck": "tsc -b tsconfig.json --diagnostics",
|
||||
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -56,60 +56,61 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@affine/cli": "workspace:*",
|
||||
"@commitlint/cli": "^19.0.0",
|
||||
"@commitlint/config-conventional": "^19.0.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@magic-works/i18n-codegen": "^0.5.0",
|
||||
"@nx/vite": "18.0.8",
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@testing-library/react": "^14.2.1",
|
||||
"@nx/vite": "17.2.8",
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@taplo/cli": "^0.5.2",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@types/affine__env": "workspace:*",
|
||||
"@types/eslint": "^8.56.3",
|
||||
"@types/node": "^20.11.20",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.4",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.6",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"@vitest/coverage-istanbul": "1.3.1",
|
||||
"@vitest/ui": "1.3.1",
|
||||
"electron": "^29.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-i": "^2.29.1",
|
||||
"@types/eslint": "^8.44.7",
|
||||
"@types/node": "^20.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"@vanilla-extract/vite-plugin": "^3.9.2",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@vitest/coverage-istanbul": "1.1.3",
|
||||
"@vitest/ui": "1.1.3",
|
||||
"electron": "^28.2.1",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-i": "^2.29.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.24.0",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"eslint-plugin-vue": "^9.22.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.23.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"fake-indexeddb": "5.0.2",
|
||||
"happy-dom": "^13.4.1",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"msw": "^2.2.1",
|
||||
"nanoid": "^5.0.6",
|
||||
"nx": "^18.0.4",
|
||||
"happy-dom": "^13.0.0",
|
||||
"husky": "^9.0.6",
|
||||
"lint-staged": "^15.1.0",
|
||||
"msw": "^2.0.8",
|
||||
"nanoid": "^5.0.3",
|
||||
"nx": "^17.2.8",
|
||||
"nyc": "^15.1.0",
|
||||
"oxlint": "0.0.22",
|
||||
"prettier": "^3.2.5",
|
||||
"semver": "^7.6.0",
|
||||
"prettier": "^3.1.0",
|
||||
"semver": "^7.5.4",
|
||||
"serve": "^14.2.1",
|
||||
"string-width": "^7.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-istanbul": "^6.0.0",
|
||||
"vite-plugin-static-copy": "^1.0.1",
|
||||
"vitest": "1.3.1",
|
||||
"string-width": "^7.0.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-istanbul": "^5.0.0",
|
||||
"vite-plugin-static-copy": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "1.1.3",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"packageManager": "yarn@4.1.1",
|
||||
"packageManager": "yarn@4.0.2",
|
||||
"resolutions": {
|
||||
"vite": "^5.0.6",
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
|
||||
@@ -167,8 +168,9 @@
|
||||
"unbox-primitive": "npm:@nolyfill/unbox-primitive@latest",
|
||||
"which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
|
||||
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
|
||||
"@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0",
|
||||
"macos-alias": "npm:@napi-rs/macos-alias@latest",
|
||||
"next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch",
|
||||
"@reforged/maker-appimage/@electron-forge/maker-base": "7.2.0",
|
||||
"macos-alias": "npm:macos-alias-building@latest",
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest",
|
||||
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "accounts" DROP CONSTRAINT "accounts_user_id_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "sessions" DROP CONSTRAINT "sessions_user_id_fkey";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_connected_accounts" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"provider" VARCHAR NOT NULL,
|
||||
"provider_account_id" VARCHAR NOT NULL,
|
||||
"scope" TEXT,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_connected_accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "multiple_users_sessions" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "multiple_users_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_sessions" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"session_id" VARCHAR(36) NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ(6),
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"token" VARCHAR(36) NOT NULL,
|
||||
"type" SMALLINT NOT NULL,
|
||||
"credential" TEXT,
|
||||
"expiresAt" TIMESTAMPTZ(6) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_connected_accounts_user_id_idx" ON "user_connected_accounts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_connected_accounts_provider_account_id_idx" ON "user_connected_accounts"("provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_sessions_session_id_user_id_key" ON "user_sessions"("session_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_type_token_key" ON "verification_tokens"("type", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_connected_accounts" ADD CONSTRAINT "user_connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "multiple_users_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "registered" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -18,48 +18,48 @@
|
||||
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.10.0",
|
||||
"@auth/prisma-adapter": "^1.4.0",
|
||||
"@aws-sdk/client-s3": "^3.515.0",
|
||||
"@apollo/server": "^4.9.5",
|
||||
"@auth/prisma-adapter": "^1.0.7",
|
||||
"@aws-sdk/client-s3": "^3.499.0",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
|
||||
"@google-cloud/opentelemetry-resource-util": "^2.1.0",
|
||||
"@keyv/redis": "^2.8.4",
|
||||
"@nestjs/apollo": "^12.1.0",
|
||||
"@nestjs/common": "^10.3.3",
|
||||
"@nestjs/core": "^10.3.3",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/graphql": "^12.1.1",
|
||||
"@nestjs/platform-express": "^10.3.3",
|
||||
"@nestjs/platform-socket.io": "^10.3.3",
|
||||
"@nestjs/schedule": "^4.0.1",
|
||||
"@nestjs/serve-static": "^4.0.1",
|
||||
"@keyv/redis": "^2.8.0",
|
||||
"@nestjs/apollo": "^12.0.11",
|
||||
"@nestjs/common": "^10.2.10",
|
||||
"@nestjs/core": "^10.2.10",
|
||||
"@nestjs/event-emitter": "^2.0.3",
|
||||
"@nestjs/graphql": "^12.0.11",
|
||||
"@nestjs/platform-express": "^10.2.10",
|
||||
"@nestjs/platform-socket.io": "^10.2.10",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/serve-static": "^4.0.0",
|
||||
"@nestjs/throttler": "^5.0.1",
|
||||
"@nestjs/websockets": "^10.3.3",
|
||||
"@node-rs/argon2": "^1.7.2",
|
||||
"@node-rs/crc32": "^1.9.2",
|
||||
"@node-rs/jsonwebtoken": "^0.5.0",
|
||||
"@nestjs/websockets": "^10.2.10",
|
||||
"@node-rs/argon2": "^1.5.2",
|
||||
"@node-rs/crc32": "^1.7.2",
|
||||
"@node-rs/jsonwebtoken": "^0.3.0",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/core": "^1.21.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.49.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.21.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.0",
|
||||
"@opentelemetry/instrumentation": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.38.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.38.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.35.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.37.0",
|
||||
"@opentelemetry/instrumentation": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
|
||||
"@opentelemetry/resources": "^1.21.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||
"@opentelemetry/sdk-node": "^0.49.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.21.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.21.0",
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@prisma/instrumentation": "^5.10.2",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/instrumentation": "^5.7.1",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"express": "^4.18.2",
|
||||
"file-type": "^19.0.0",
|
||||
@@ -71,49 +71,50 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"keyv": "^4.5.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.6",
|
||||
"nest-commander": "^3.12.5",
|
||||
"nanoid": "^5.0.3",
|
||||
"nest-commander": "^3.12.2",
|
||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||
"nodemailer": "^6.9.10",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.7",
|
||||
"on-headers": "^1.0.2",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"prisma": "^5.10.2",
|
||||
"prom-client": "^15.1.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"prisma": "^5.7.1",
|
||||
"prom-client": "^15.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.6.0",
|
||||
"socket.io": "^4.7.4",
|
||||
"stripe": "^14.18.0",
|
||||
"semver": "^7.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
"stripe": "^14.5.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"ws": "^8.16.0",
|
||||
"yjs": "^13.6.12",
|
||||
"ws": "^8.14.2",
|
||||
"yjs": "^13.6.10",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@affine/storage": "workspace:*",
|
||||
"@napi-rs/image": "^1.9.1",
|
||||
"@nestjs/testing": "^10.3.3",
|
||||
"@napi-rs/image": "^1.7.0",
|
||||
"@nestjs/testing": "^10.2.10",
|
||||
"@types/cookie-parser": "^1.4.6",
|
||||
"@types/engine.io": "^3.1.10",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/graphql-upload": "^16.0.7",
|
||||
"@types/graphql-upload": "^16.0.5",
|
||||
"@types/keyv": "^4.2.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.11.20",
|
||||
"@types/lodash-es": "^4.17.11",
|
||||
"@types/node": "^20.9.3",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/on-headers": "^1.0.3",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/sinon": "^17.0.2",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"ava": "^6.1.1",
|
||||
"c8": "^9.1.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"ava": "^6.0.0",
|
||||
"c8": "^9.0.0",
|
||||
"nodemon": "^3.0.1",
|
||||
"sinon": "^17.0.1",
|
||||
"supertest": "^6.3.4"
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"ava": {
|
||||
"timeout": "1m",
|
||||
@@ -125,7 +126,8 @@
|
||||
"--trace-sigint",
|
||||
"--loader",
|
||||
"ts-node/esm/transpile-only.mjs",
|
||||
"--es-module-specifier-resolution=node"
|
||||
"--es-module-specifier-resolution",
|
||||
"node"
|
||||
],
|
||||
"files": [
|
||||
"tests/**/*.spec.ts",
|
||||
@@ -142,8 +144,7 @@
|
||||
"MAILER_USER": "noreply@toeverything.info",
|
||||
"MAILER_PASSWORD": "affine",
|
||||
"MAILER_SENDER": "noreply@toeverything.info",
|
||||
"FEATURES_EARLY_ACCESS_PREVIEW": "false",
|
||||
"DEPLOYMENT_TYPE": "affine"
|
||||
"FEATURES_EARLY_ACCESS_PREVIEW": "false"
|
||||
}
|
||||
},
|
||||
"nodemonConfig": {
|
||||
@@ -152,7 +153,8 @@
|
||||
"nodeArgs": [
|
||||
"--loader",
|
||||
"ts-node/esm.mjs",
|
||||
"--es-module-specifier-resolution=node"
|
||||
"--es-module-specifier-resolution",
|
||||
"node"
|
||||
],
|
||||
"ignore": [
|
||||
"**/__tests__/**",
|
||||
|
||||
@@ -10,83 +10,28 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String
|
||||
email String @unique
|
||||
emailVerifiedAt DateTime? @map("email_verified")
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String
|
||||
email String @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
// image field is for the next-auth
|
||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
/// 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)
|
||||
password String? @db.VarChar
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
features UserFeatures[]
|
||||
customer UserStripeCustomer?
|
||||
subscription UserSubscription?
|
||||
invoices UserInvoice[]
|
||||
workspacePermissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
connectedAccounts ConnectedAccount[]
|
||||
sessions UserSession[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model ConnectedAccount {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
provider String @db.VarChar
|
||||
providerAccountId String @map("provider_account_id") @db.VarChar
|
||||
scope String? @db.Text
|
||||
accessToken String? @map("access_token") @db.Text
|
||||
refreshToken String? @map("refresh_token") @db.Text
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([providerAccountId])
|
||||
@@map("user_connected_accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
userSessions UserSession[]
|
||||
|
||||
@@map("multiple_users_sessions")
|
||||
}
|
||||
|
||||
model UserSession {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
sessionId String @map("session_id") @db.VarChar(36)
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, userId])
|
||||
@@map("user_sessions")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
token String @db.VarChar(36)
|
||||
type Int @db.SmallInt
|
||||
credential String? @db.Text
|
||||
expiresAt DateTime @db.Timestamptz(6)
|
||||
|
||||
@@unique([type, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model Workspace {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
@@ -241,7 +186,7 @@ model Features {
|
||||
@@map("features")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthAccount {
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
@@ -255,20 +200,23 @@ model DeprecatedNextAuthAccount {
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthSession {
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model DeprecatedNextAuthVerificationToken {
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
37
packages/backend/server/scripts/init-db.ts
Normal file
37
packages/backend/server/scripts/init-db.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
...userA,
|
||||
password: await hash(userA.password),
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by api sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature: 'free_plan_v1',
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
import { Public } from './core/auth';
|
||||
import { Config } from './fundamentals/config';
|
||||
|
||||
@Controller('/')
|
||||
export class AppController {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
info() {
|
||||
return {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { get } from 'lodash-es';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthGuard, AuthModule } from './core/auth';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
|
||||
import { DocModule } from './core/doc';
|
||||
import { FeatureModule } from './core/features';
|
||||
import { QuotaModule } from './core/quota';
|
||||
import { StorageModule } from './core/storage';
|
||||
import { SyncModule } from './core/sync';
|
||||
import { UserModule } from './core/user';
|
||||
import { UsersModule } from './core/users';
|
||||
import { WorkspaceModule } from './core/workspaces';
|
||||
import { getOptionalModuleMetadata } from './fundamentals';
|
||||
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
|
||||
@@ -25,28 +25,24 @@ import {
|
||||
} from './fundamentals/config';
|
||||
import { EventModule } from './fundamentals/event';
|
||||
import { GqlModule } from './fundamentals/graphql';
|
||||
import { HelpersModule } from './fundamentals/helpers';
|
||||
import { MailModule } from './fundamentals/mailer';
|
||||
import { MetricsModule } from './fundamentals/metrics';
|
||||
import { MutexModule } from './fundamentals/mutex';
|
||||
import { PrismaModule } from './fundamentals/prisma';
|
||||
import { StorageProviderModule } from './fundamentals/storage';
|
||||
import { SessionModule } from './fundamentals/session';
|
||||
import { RateLimiterModule } from './fundamentals/throttler';
|
||||
import { WebSocketModule } from './fundamentals/websocket';
|
||||
import { REGISTERED_PLUGINS } from './plugins';
|
||||
import { pluginsMap } from './plugins';
|
||||
|
||||
export const FunctionalityModules = [
|
||||
ConfigModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
EventModule,
|
||||
CacheModule,
|
||||
MutexModule,
|
||||
PrismaModule,
|
||||
MetricsModule,
|
||||
RateLimiterModule,
|
||||
SessionModule,
|
||||
MailModule,
|
||||
StorageProviderModule,
|
||||
HelpersModule,
|
||||
];
|
||||
|
||||
export class AppModuleBuilder {
|
||||
@@ -111,10 +107,6 @@ export class AppModuleBuilder {
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: CacheInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: AuthGuard,
|
||||
},
|
||||
],
|
||||
imports: this.modules,
|
||||
controllers: this.config.isSelfhosted ? [] : [AppController],
|
||||
@@ -147,7 +139,7 @@ function buildAppModule() {
|
||||
WebSocketModule,
|
||||
GqlModule,
|
||||
StorageModule,
|
||||
UserModule,
|
||||
UsersModule,
|
||||
WorkspaceModule,
|
||||
FeatureModule,
|
||||
QuotaModule
|
||||
@@ -163,7 +155,7 @@ function buildAppModule() {
|
||||
|
||||
// plugin modules
|
||||
AFFiNE.plugins.enabled.forEach(name => {
|
||||
const plugin = REGISTERED_PLUGINS.get(name as AvailablePlugins);
|
||||
const plugin = pluginsMap.get(name as AvailablePlugins);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin ${name}`);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import { GlobalExceptionFilter } from './fundamentals';
|
||||
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { SocketIoAdapter } from './fundamentals';
|
||||
import { SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { ExceptionLogger } from './middleware/exception-logger';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
|
||||
export async function createApp() {
|
||||
@@ -28,7 +29,7 @@ export async function createApp() {
|
||||
})
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||
app.useGlobalFilters(new ExceptionLogger());
|
||||
app.use(cookieParser());
|
||||
|
||||
if (AFFiNE.flavor.sync) {
|
||||
|
||||
@@ -7,10 +7,12 @@ AFFiNE.ENV_MAP = {
|
||||
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',
|
||||
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
|
||||
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
|
||||
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
|
||||
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
|
||||
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
|
||||
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
|
||||
MAILER_HOST: 'mailer.host',
|
||||
MAILER_PORT: ['mailer.port', 'int'],
|
||||
MAILER_USER: 'mailer.auth.user',
|
||||
@@ -32,8 +34,4 @@ AFFiNE.ENV_MAP = {
|
||||
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',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -20,19 +20,19 @@ const env = process.env;
|
||||
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
||||
|
||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
AFFiNE.plugins.use('cloudflare-r2', {
|
||||
AFFiNE.storage.providers.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.provider = 'r2';
|
||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
||||
`https://avatar.affineassets.com/${key}`;
|
||||
|
||||
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.blob.provider = 'r2';
|
||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||
}`;
|
||||
@@ -40,7 +40,6 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
|
||||
AFFiNE.plugins.use('redis');
|
||||
AFFiNE.plugins.use('payment');
|
||||
AFFiNE.plugins.use('oauth');
|
||||
|
||||
if (AFFiNE.deploy) {
|
||||
AFFiNE.mailer = {
|
||||
|
||||
@@ -87,55 +87,8 @@ AFFiNE.port = 3010;
|
||||
AFFiNE.plugins.use('redis', {
|
||||
/* override options */
|
||||
});
|
||||
//
|
||||
//
|
||||
// /* Payment Plugin */
|
||||
AFFiNE.plugins.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', {
|
||||
// accountId: '',
|
||||
// credentials: {
|
||||
// accessKeyId: '',
|
||||
// secretAccessKey: '',
|
||||
// },
|
||||
// });
|
||||
//
|
||||
// /* AWS S3 Plugin */
|
||||
// /* Enable if you choose to store workspace blobs or user avatars in AWS S3 Storage Service */
|
||||
// AFFiNE.plugins.use('aws-s3', {
|
||||
// credentials: {
|
||||
// accessKeyId: '',
|
||||
// secretAccessKey: '',
|
||||
// })
|
||||
// /* Update the provider of storages */
|
||||
// AFFiNE.storage.storages.blob.provider = 'r2';
|
||||
// AFFiNE.storage.storages.avatar.provider = 'r2';
|
||||
//
|
||||
// /* OAuth Plugin */
|
||||
// AFFiNE.plugins.use('oauth', {
|
||||
// providers: {
|
||||
// github: {
|
||||
// clientId: '',
|
||||
// clientSecret: '',
|
||||
// // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
|
||||
// args: {
|
||||
// scope: 'user',
|
||||
// },
|
||||
// },
|
||||
// google: {
|
||||
// clientId: '',
|
||||
// clientSecret: '',
|
||||
// args: {
|
||||
// // See https://developers.google.com/identity/protocols/oauth2
|
||||
// scope: 'openid email profile',
|
||||
// promot: 'select_account',
|
||||
// access_type: 'offline',
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import {
|
||||
Config,
|
||||
PaymentRequiredException,
|
||||
URLHelper,
|
||||
} from '../../fundamentals';
|
||||
import { UserService } from '../user';
|
||||
import { validators } from '../utils/validators';
|
||||
import { CurrentUser } from './current-user';
|
||||
import { Public } from './guard';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
class SignInCredential {
|
||||
email!: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@Controller('/api/auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly token: TokenService
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('/sign-in')
|
||||
@Header('content-type', 'application/json')
|
||||
async signIn(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Body() credential: SignInCredential,
|
||||
@Query('redirect_uri') redirectUri = this.url.home
|
||||
) {
|
||||
validators.assertValidEmail(credential.email);
|
||||
const canSignIn = await this.auth.canSignIn(credential.email);
|
||||
if (!canSignIn) {
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
);
|
||||
}
|
||||
|
||||
if (credential.password) {
|
||||
validators.assertValidPassword(credential.password);
|
||||
const user = await this.auth.signIn(
|
||||
credential.email,
|
||||
credential.password
|
||||
);
|
||||
|
||||
await this.auth.setCookie(req, res, user);
|
||||
res.send(user);
|
||||
} else {
|
||||
// send email magic link
|
||||
const user = await this.user.findUserByEmail(credential.email);
|
||||
const result = await this.sendSignInEmail(
|
||||
{ email: credential.email, signUp: !user },
|
||||
redirectUri
|
||||
);
|
||||
|
||||
if (result.rejected.length) {
|
||||
throw new Error('Failed to send sign-in email.');
|
||||
}
|
||||
|
||||
res.send({
|
||||
email: credential.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendSignInEmail(
|
||||
{ email, signUp }: { email: string; signUp: boolean },
|
||||
redirectUri: string
|
||||
) {
|
||||
const token = await this.token.createToken(TokenType.SignIn, email);
|
||||
|
||||
const magicLink = this.url.link('/api/auth/magic-link', {
|
||||
token,
|
||||
email,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const result = await this.auth.sendSignInEmail(email, magicLink, signUp);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('/sign-out')
|
||||
async signOut(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('redirect_uri') redirectUri?: string
|
||||
) {
|
||||
const session = await this.auth.signOut(
|
||||
req.cookies[AuthService.sessionCookieName],
|
||||
parseAuthUserSeqNum(req.headers[AuthService.authUserSeqHeaderName])
|
||||
);
|
||||
|
||||
if (session) {
|
||||
res.cookie(AuthService.sessionCookieName, session.id, {
|
||||
expires: session.expiresAt ?? void 0, // expiredAt is `string | null`
|
||||
...this.auth.cookieOptions,
|
||||
});
|
||||
} else {
|
||||
res.clearCookie(AuthService.sessionCookieName);
|
||||
}
|
||||
|
||||
if (redirectUri) {
|
||||
return this.url.safeRedirect(res, redirectUri);
|
||||
} else {
|
||||
return res.send(null);
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/magic-link')
|
||||
async magicLinkSignIn(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('token') token?: string,
|
||||
@Query('email') email?: string,
|
||||
@Query('redirect_uri') redirectUri = this.url.home
|
||||
) {
|
||||
if (!token || !email) {
|
||||
throw new BadRequestException('Invalid Sign-in mail Token');
|
||||
}
|
||||
|
||||
email = decodeURIComponent(email);
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
|
||||
credential: email,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new BadRequestException('Invalid Sign-in mail Token');
|
||||
}
|
||||
|
||||
const user = await this.user.fulfillUser(email, {
|
||||
emailVerifiedAt: new Date(),
|
||||
registered: true,
|
||||
});
|
||||
|
||||
await this.auth.setCookie(req, res, user);
|
||||
|
||||
return this.url.safeRedirect(res, redirectUri);
|
||||
}
|
||||
|
||||
@Get('/authorize')
|
||||
async authorize(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Query('redirect_uri') redirect_uri?: string
|
||||
) {
|
||||
const session = await this.auth.createUserSession(
|
||||
user,
|
||||
undefined,
|
||||
this.config.auth.accessToken.ttl
|
||||
);
|
||||
|
||||
this.url.link(redirect_uri ?? '/open-app/redirect', {
|
||||
token: session.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/session')
|
||||
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
|
||||
return {
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/sessions')
|
||||
async currentSessionUsers(@Req() req: Request) {
|
||||
const token = req.cookies[AuthService.sessionCookieName];
|
||||
if (!token) {
|
||||
return {
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
users: await this.auth.getUserList(token),
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('/challenge')
|
||||
async challenge() {
|
||||
// TODO: impl in following PR
|
||||
return {
|
||||
challenge: randomUUID(),
|
||||
resource: randomUUID(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { User } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../fundamentals';
|
||||
|
||||
function getUserFromContext(context: ExecutionContext) {
|
||||
return getRequestResponseFromContext(context).req.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token or session cookie is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user')
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // for public apis
|
||||
* \@Public()
|
||||
* \@Get('/session')
|
||||
* session(@currentUser() user?: CurrentUser) {
|
||||
* return user
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
// interface and variable don't conflict
|
||||
// eslint-disable-next-line no-redeclare
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
export interface CurrentUser
|
||||
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name'> {
|
||||
hasPassword: boolean | null;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
@@ -1,90 +1,128 @@
|
||||
import type {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import {
|
||||
createParamDecorator,
|
||||
Inject,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import { Config, getRequestResponseFromContext } from '../../fundamentals';
|
||||
import { AuthService, parseAuthUserSeqNum } from './service';
|
||||
import {
|
||||
getRequestResponseFromContext,
|
||||
PrismaService,
|
||||
} from '../../fundamentals';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
function extractTokenFromHeader(authorization: string) {
|
||||
if (!/^Bearer\s/i.test(authorization)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return authorization.substring(7);
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
return getRequestResponseFromContext(context).req.user;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
private auth!: AuthService;
|
||||
/**
|
||||
* Used to fetch current user from the request context.
|
||||
*
|
||||
* > The user may be undefined if authorization token is not provided.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* // Graphql Query
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ```typescript
|
||||
* // HTTP Controller
|
||||
* \@Get('/user)
|
||||
* user(@CurrentUser() user?: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getUserFromContext(context);
|
||||
}
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly ref: ModuleRef,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly auth: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.auth = this.ref.get(AuthService, { strict: false });
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
|
||||
// check cookie
|
||||
let sessionToken: string | undefined =
|
||||
req.cookies[AuthService.sessionCookieName];
|
||||
|
||||
// backward compatibility for client older then 0.12
|
||||
// TODO: remove
|
||||
if (!sessionToken) {
|
||||
sessionToken =
|
||||
req.cookies[
|
||||
this.config.https
|
||||
? '__Secure-next-auth.session-token'
|
||||
: 'next-auth.session-token'
|
||||
];
|
||||
}
|
||||
|
||||
if (!sessionToken && req.headers.authorization) {
|
||||
sessionToken = extractTokenFromHeader(req.headers.authorization);
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
const userSeq = parseAuthUserSeqNum(
|
||||
req.headers[AuthService.authUserSeqHeaderName]
|
||||
);
|
||||
|
||||
const user = await this.auth.getUser(sessionToken, userSeq);
|
||||
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.get<boolean>(
|
||||
'isPublic',
|
||||
context.getHandler()
|
||||
);
|
||||
// api can be public, but if user is logged in, we can get user info
|
||||
const isPublicable = this.reflector.get<boolean>(
|
||||
'isPublicable',
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
} else if (!token) {
|
||||
if (!req.cookies) {
|
||||
return isPublicable;
|
||||
}
|
||||
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException('You are not signed in.');
|
||||
}
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
|
||||
return true;
|
||||
const { body = {}, cookies, status = 200 } = session;
|
||||
if (!body && !isPublicable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @ts-expect-error body is user here
|
||||
req.user = body.user;
|
||||
if (cookies && res) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
status === 200 &&
|
||||
typeof body !== 'string' &&
|
||||
// ignore body if api is publicable
|
||||
(Object.keys(body).length || isPublicable)
|
||||
);
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.auth.verify(jwt);
|
||||
req.user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +137,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
* ```typescript
|
||||
* \@Auth()
|
||||
* \@Query(() => UserType)
|
||||
* user(@CurrentUser() user: CurrentUser) {
|
||||
* user(@CurrentUser() user: User) {
|
||||
* return user;
|
||||
* }
|
||||
* ```
|
||||
@@ -110,3 +148,5 @@ export const Auth = () => {
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
// api is public accessible, but if user is logged in, we can get user info
|
||||
export const Publicable = () => SetMetadata('isPublicable', true);
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { UserModule } from '../user';
|
||||
import { AuthController } from './controller';
|
||||
import { NextAuthController } from './next-auth.controller';
|
||||
import { NextAuthOptionsProvider } from './next-auth-options';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService } from './token';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [FeatureModule, UserModule],
|
||||
providers: [AuthService, AuthResolver, TokenService],
|
||||
exports: [AuthService],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
|
||||
exports: [AuthService, NextAuthOptionsProvider],
|
||||
controllers: [NextAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
export * from './guard';
|
||||
export { ClientTokenType } from './resolver';
|
||||
export { TokenType } from './resolver';
|
||||
export { AuthService };
|
||||
export * from './current-user';
|
||||
|
||||
285
packages/backend/server/src/core/auth/next-auth-options.ts
Normal file
285
packages/backend/server/src/core/auth/next-auth-options.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import Email from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import {
|
||||
Config,
|
||||
MailService,
|
||||
PrismaService,
|
||||
SessionService,
|
||||
} from '../../fundamentals';
|
||||
import { FeatureType } from '../features';
|
||||
import { Quota_FreePlanV1_1 } from '../quota';
|
||||
import {
|
||||
decode,
|
||||
encode,
|
||||
sendVerificationRequest,
|
||||
SendVerificationRequestParams,
|
||||
} from './utils';
|
||||
|
||||
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||
|
||||
const TrustedProviders = ['google'];
|
||||
|
||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
provide: NextAuthOptionsProvide,
|
||||
useFactory(
|
||||
config: Config,
|
||||
prisma: PrismaService,
|
||||
mailer: MailService,
|
||||
session: SessionService
|
||||
) {
|
||||
const logger = new Logger('NextAuth');
|
||||
const prismaAdapter = PrismaAdapter(prisma);
|
||||
// createUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
|
||||
prismaAdapter.createUser = async data => {
|
||||
const userData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
avatarUrl: '',
|
||||
emailVerified: data.emailVerified,
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by email sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (data.email && !data.name) {
|
||||
userData.name = data.email.split('@')[0];
|
||||
}
|
||||
if (data.image) {
|
||||
userData.avatarUrl = data.image;
|
||||
}
|
||||
return createUser(userData);
|
||||
};
|
||||
// linkAccount exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const linkAccount = prismaAdapter.linkAccount!.bind(prismaAdapter);
|
||||
prismaAdapter.linkAccount = async account => {
|
||||
// google account must be a verified email
|
||||
if (TrustedProviders.includes(account.provider)) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: account.userId,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return linkAccount(account) as Promise<void>;
|
||||
};
|
||||
// getUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
|
||||
prismaAdapter.getUser = async id => {
|
||||
const result = await getUser(id);
|
||||
if (result) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.image = result.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.hasPassword = Boolean(result.password);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
prismaAdapter.createVerificationToken = async data => {
|
||||
await session.set(
|
||||
`${data.identifier}:${data.token}`,
|
||||
Date.now() + session.sessionTtl
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
|
||||
const expires = await session.get(`${identifier}:${token}`);
|
||||
if (expires) {
|
||||
return { identifier, token, expires: new Date(expires) };
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const nextAuthOptions: NextAuthOptions = {
|
||||
providers: [],
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.node.prod,
|
||||
logger: {
|
||||
debug(code, metadata) {
|
||||
logger.debug(`${code}: ${JSON.stringify(metadata)}`);
|
||||
},
|
||||
error(code, metadata) {
|
||||
if (metadata instanceof Error) {
|
||||
// @ts-expect-error assign code to error
|
||||
metadata.code = code;
|
||||
logger.error(metadata);
|
||||
} else if (metadata.error instanceof Error) {
|
||||
assign(metadata.error, omit(metadata, 'error'), { code });
|
||||
logger.error(metadata.error);
|
||||
}
|
||||
},
|
||||
warn(code) {
|
||||
logger.warn(code);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Credentials.default({
|
||||
name: 'Password',
|
||||
credentials: {
|
||||
email: {
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
placeholder: 'torvalds@osdl.org',
|
||||
},
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(
|
||||
credentials:
|
||||
| Record<'email' | 'password' | 'hashedPassword', string>
|
||||
| undefined
|
||||
) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
const { password, hashedPassword } = credentials;
|
||||
if (!password || !hashedPassword) {
|
||||
return null;
|
||||
}
|
||||
if (!(await verify(hashedPassword, password))) {
|
||||
return null;
|
||||
}
|
||||
return credentials;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (config.mailer && mailer) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Email.default({
|
||||
sendVerificationRequest: (params: SendVerificationRequestParams) =>
|
||||
sendVerificationRequest(config, logger, mailer, session, params),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Github.default({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google?.enabled) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Google.default({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
checks: 'nonce',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
authorization: {
|
||||
params: { scope: 'openid email profile', prompt: 'select_account' },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (nextAuthOptions.providers.length > 1) {
|
||||
// not only credentials provider
|
||||
nextAuthOptions.session = { strategy: 'database' };
|
||||
}
|
||||
|
||||
nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) =>
|
||||
encode(config, prisma, token, maxAge),
|
||||
decode: async ({ token }) => decode(config, token),
|
||||
};
|
||||
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
|
||||
nextAuthOptions.callbacks = {
|
||||
session: async ({ session, user, token }) => {
|
||||
if (session.user) {
|
||||
if (user) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = user.id;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.image = user.image ?? user.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = user.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = Boolean(user.password);
|
||||
} else {
|
||||
// technically the sub should be the same as id
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = token.sub;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = token.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = token.hasPassword;
|
||||
}
|
||||
if (token && token.picture) {
|
||||
session.user.image = token.picture;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
signIn: async ({ profile, user }) => {
|
||||
if (!config.featureFlags.earlyAccessPreview) {
|
||||
return true;
|
||||
}
|
||||
const email = profile?.email ?? user.email;
|
||||
if (email) {
|
||||
// FIXME: cannot inject FeatureManagementService here
|
||||
// it will cause prisma.account to be undefined
|
||||
// then prismaAdapter.getUserByAccount will throw error
|
||||
if (email.endsWith('@toeverything.info')) return true;
|
||||
return prisma.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
redirect({ url }) {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
nextAuthOptions.pages = {
|
||||
newUser: '/auth/onboarding',
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService, SessionService],
|
||||
};
|
||||
409
packages/backend/server/src/core/auth/next-auth.controller.ts
Normal file
409
packages/backend/server/src/core/auth/next-auth.controller.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { URLSearchParams } from 'node:url';
|
||||
|
||||
import {
|
||||
All,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Inject,
|
||||
Logger,
|
||||
Next,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { pick } from 'lodash-es';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import {
|
||||
AuthThrottlerGuard,
|
||||
Config,
|
||||
metrics,
|
||||
PrismaService,
|
||||
SessionService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
const BASE_URL = '/api/auth/';
|
||||
|
||||
const DEFAULT_SESSION_EXPIRE_DATE = 2592000 * 1000; // 30 days
|
||||
|
||||
@Controller(BASE_URL)
|
||||
export class NextAuthController {
|
||||
private readonly callbackSession;
|
||||
|
||||
private readonly logger = new Logger('NextAuthController');
|
||||
|
||||
constructor(
|
||||
readonly config: Config,
|
||||
readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private readonly session: SessionService
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 60,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Get('/challenge')
|
||||
async getChallenge(@Res() res: Response) {
|
||||
const challenge = nanoid();
|
||||
const resource = nanoid();
|
||||
await this.session.set(challenge, resource, 5 * 60 * 1000);
|
||||
res.json({ challenge, resource });
|
||||
}
|
||||
|
||||
@UseGuards(AuthThrottlerGuard)
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 60,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@All('*')
|
||||
async auth(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query() query: Record<string, any>,
|
||||
@Next() next: NextFunction
|
||||
) {
|
||||
if (req.path === '/api/auth/signin' && req.method === 'GET') {
|
||||
const query = req.query
|
||||
? // @ts-expect-error req.query is satisfy with the Record<string, any>
|
||||
`?${new URLSearchParams(req.query).toString()}`
|
||||
: '';
|
||||
res.redirect(`/signin${query}`);
|
||||
return;
|
||||
}
|
||||
const [action, providerId] = req.url // start with request url
|
||||
.slice(BASE_URL.length) // make relative to baseUrl
|
||||
.replace(/\?.*/, '') // remove query part, use only path part
|
||||
.split('/') as [AuthAction, string]; // as array of strings;
|
||||
|
||||
metrics.auth.counter('call_counter').add(1, { action, providerId });
|
||||
|
||||
const credentialsSignIn =
|
||||
req.method === 'POST' && providerId === 'credentials';
|
||||
let userId: string | undefined;
|
||||
if (credentialsSignIn) {
|
||||
const { email } = req.body;
|
||||
if (email) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
req.statusCode = 401;
|
||||
req.statusMessage = 'User not found';
|
||||
req.body = null;
|
||||
throw new NotFoundException(`User not found`);
|
||||
} else {
|
||||
userId = user.id;
|
||||
req.body = {
|
||||
...req.body,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatarUrl,
|
||||
hashedPassword: user.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const options = this.nextAuthOptions;
|
||||
if (req.method === 'POST' && action === 'session') {
|
||||
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'invalid_session_data' });
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
const user = await this.updateSession(req, req.body.data);
|
||||
// callbacks.session existed
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = ({ session }) => {
|
||||
return {
|
||||
user: {
|
||||
...pick(user, 'id', 'name', 'email'),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: !!user.password,
|
||||
},
|
||||
expires: session.expires,
|
||||
};
|
||||
};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = this.callbackSession;
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.auth.captcha.enable &&
|
||||
req.method === 'POST' &&
|
||||
action === 'signin' &&
|
||||
// TODO: add credentials support in frontend
|
||||
['email'].includes(providerId)
|
||||
) {
|
||||
const isVerified = await this.verifyChallenge(req, res);
|
||||
if (!isVerified) return;
|
||||
}
|
||||
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
query: query,
|
||||
method: req.method,
|
||||
action,
|
||||
providerId,
|
||||
error: query.error ?? providerId,
|
||||
cookies: req.cookies,
|
||||
},
|
||||
options,
|
||||
});
|
||||
|
||||
if (headers) {
|
||||
for (const { key, value } of headers) {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
if (cookies) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined;
|
||||
const secureCookiePrefix = '__Secure-';
|
||||
const sessionCookieName = `next-auth.session-token`;
|
||||
// next-auth credentials login only support JWT strategy
|
||||
// https://next-auth.js.org/configuration/providers/credentials
|
||||
// let's store the session token in the database
|
||||
if (
|
||||
credentialsSignIn &&
|
||||
(nextAuthTokenCookie = cookies?.find(
|
||||
({ name }) =>
|
||||
name === sessionCookieName ||
|
||||
name === `${secureCookiePrefix}${sessionCookieName}`
|
||||
))
|
||||
) {
|
||||
const cookieExpires = new Date();
|
||||
cookieExpires.setTime(
|
||||
cookieExpires.getTime() + DEFAULT_SESSION_EXPIRE_DATE
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await this.nextAuthOptions.adapter!.createSession!({
|
||||
sessionToken: nextAuthTokenCookie.value,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
userId: userId!,
|
||||
expires: cookieExpires,
|
||||
});
|
||||
}
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
this.logger.log(`Early access redirect headers: ${req.headers}`);
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'no_early_access_permission' });
|
||||
|
||||
if (
|
||||
!req.headers?.referer ||
|
||||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
|
||||
) {
|
||||
res.redirect('https://community.affine.pro/c/insider-general/');
|
||||
} else {
|
||||
res.status(403);
|
||||
res.json({
|
||||
url: 'https://community.affine.pro/c/insider-general/',
|
||||
error: `You don't have early access permission`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
if (providerId === 'credentials') {
|
||||
res.send(JSON.stringify({ ok: true, url: redirect }));
|
||||
} else if (
|
||||
action === 'callback' ||
|
||||
action === 'error' ||
|
||||
(providerId !== 'credentials' &&
|
||||
// login in the next-auth page, /api/auth/signin, auto redirect.
|
||||
// otherwise, return the json value to allow frontend to handle the redirect.
|
||||
req.headers?.referer?.includes?.('/api/auth/signin'))
|
||||
) {
|
||||
res.redirect(redirect);
|
||||
} else {
|
||||
res.json({ url: redirect });
|
||||
}
|
||||
} else if (typeof body === 'string') {
|
||||
res.send(body);
|
||||
} else if (body && typeof body === 'object') {
|
||||
res.json(body);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSession(
|
||||
req: Request,
|
||||
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
|
||||
): Promise<User> {
|
||||
const { name, email, password, oldPassword } = newSession;
|
||||
if (!name && !email && !password) {
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
if (password) {
|
||||
const user = await this.verifyUserFromRequest(req);
|
||||
const { password: userPassword } = user;
|
||||
if (!oldPassword) {
|
||||
if (userPassword) {
|
||||
throw new BadRequestException(
|
||||
`Old password is required to update password`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!userPassword) {
|
||||
throw new BadRequestException(`No existed password`);
|
||||
}
|
||||
if (await verify(userPassword, oldPassword)) {
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
...pick(newSession, 'email', 'name'),
|
||||
password: await hash(password),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return user;
|
||||
} else {
|
||||
const user = await this.verifyUserFromRequest(req);
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: pick(newSession, 'name', 'email'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyChallenge(req: Request, res: Response): Promise<boolean> {
|
||||
const challenge = req.query?.challenge;
|
||||
if (typeof challenge === 'string' && challenge) {
|
||||
const resource = await this.session.get(challenge);
|
||||
|
||||
if (!resource) {
|
||||
this.rejectResponse(res, 'Invalid Challenge');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isChallengeVerified =
|
||||
await this.authService.verifyChallengeResponse(
|
||||
req.query?.token,
|
||||
resource
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Challenge: ${challenge}, Resource: ${resource}, Response: ${req.query?.token}, isChallengeVerified: ${isChallengeVerified}`
|
||||
);
|
||||
|
||||
if (!isChallengeVerified) {
|
||||
this.rejectResponse(res, 'Invalid Challenge Response');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const isTokenVerified = await this.authService.verifyCaptchaToken(
|
||||
req.query?.token,
|
||||
req.headers['CF-Connecting-IP'] as string
|
||||
);
|
||||
|
||||
if (!isTokenVerified) {
|
||||
this.rejectResponse(res, 'Invalid Captcha Response');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async verifyUserFromRequest(req: Request): Promise<User> {
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
|
||||
const { body } = session;
|
||||
// @ts-expect-error check if body.user exists
|
||||
if (body && body.user && body.user.id) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
// @ts-expect-error body.user.id exists
|
||||
id: body.user.id,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.authService.verify(jwt);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
|
||||
rejectResponse(res: Response, error: string, status = 400) {
|
||||
res.status(status);
|
||||
res.json({
|
||||
url: `${this.config.baseUrl}/api/auth/error?${new URLSearchParams({
|
||||
error,
|
||||
}).toString()}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const checkUrlOrigin = (url: string, origin: string) => {
|
||||
try {
|
||||
return new URL(url).origin === origin;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -10,22 +10,24 @@ import {
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Request } from 'express';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
|
||||
import { UserType } from '../user/types';
|
||||
import { validators } from '../utils/validators';
|
||||
import { CurrentUser } from './current-user';
|
||||
import { Public } from './guard';
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
Config,
|
||||
SessionService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { UserType } from '../users';
|
||||
import { Auth, CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
import { TokenService, TokenType } from './token';
|
||||
|
||||
@ObjectType('tokenType')
|
||||
export class ClientTokenType {
|
||||
@ObjectType()
|
||||
export class TokenType {
|
||||
@Field()
|
||||
token!: string;
|
||||
|
||||
@@ -48,57 +50,46 @@ export class AuthResolver {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly auth: AuthService,
|
||||
private readonly token: TokenService
|
||||
private readonly session: SessionService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Public()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
currentUser(@CurrentUser() user?: CurrentUser): UserType | undefined {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 20,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@ResolveField(() => ClientTokenType, {
|
||||
name: 'token',
|
||||
deprecationReason: 'use [/api/auth/authorize]',
|
||||
})
|
||||
async clientToken(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@ResolveField(() => TokenType)
|
||||
async token(
|
||||
@Context() ctx: { req: Request },
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Parent() user: UserType
|
||||
): Promise<ClientTokenType> {
|
||||
) {
|
||||
if (user.id !== currentUser.id) {
|
||||
throw new ForbiddenException('Invalid user');
|
||||
throw new BadRequestException('Invalid user');
|
||||
}
|
||||
|
||||
const session = await this.auth.createUserSession(
|
||||
user,
|
||||
undefined,
|
||||
this.config.auth.accessToken.ttl
|
||||
);
|
||||
let sessionToken: string | undefined;
|
||||
|
||||
// only return session if the request is from the same origin & path == /open-app
|
||||
if (
|
||||
ctx.req.headers.referer &&
|
||||
ctx.req.headers.host &&
|
||||
new URL(ctx.req.headers.referer).pathname.startsWith('/open-app') &&
|
||||
ctx.req.headers.host === new URL(this.config.origin).host
|
||||
) {
|
||||
const cookiePrefix = this.config.node.prod ? '__Secure-' : '';
|
||||
const sessionCookieName = `${cookiePrefix}next-auth.session-token`;
|
||||
sessionToken = ctx.req.cookies?.[sessionCookieName];
|
||||
}
|
||||
|
||||
return {
|
||||
sessionToken: session.sessionId,
|
||||
token: session.sessionId,
|
||||
refresh: '',
|
||||
sessionToken,
|
||||
token: this.auth.sign(user),
|
||||
refresh: this.auth.refresh(user),
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -107,19 +98,16 @@ export class AuthResolver {
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request; res: Response },
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('name') name: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
validators.assertValidCredential({ email, password });
|
||||
const user = await this.auth.signUp(name, email, password);
|
||||
await this.auth.setCookie(ctx.req, ctx.res, user);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -128,13 +116,11 @@ export class AuthResolver {
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
async signIn(
|
||||
@Context() ctx: { req: Request; res: Response },
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
validators.assertValidCredential({ email, password });
|
||||
const user = await this.auth.signIn(email, password);
|
||||
await this.auth.setCookie(ctx.req, ctx.res, user);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
@@ -146,26 +132,28 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changePassword(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
validators.assertValidPassword(newPassword);
|
||||
// NOTE: Set & Change password are using the same token type.
|
||||
const valid = await this.token.verifyToken(
|
||||
TokenType.ChangePassword,
|
||||
token,
|
||||
{
|
||||
credential: user.id,
|
||||
}
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
const id = await this.session.get(token);
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify the email first');
|
||||
}
|
||||
if (
|
||||
!id ||
|
||||
(id !== user.id &&
|
||||
// change password after sign in with email link
|
||||
// we only create user account after user sign in with email link
|
||||
id !== user.email)
|
||||
) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(user.email, newPassword);
|
||||
await this.session.delete(token);
|
||||
|
||||
return user;
|
||||
}
|
||||
@@ -177,24 +165,25 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType)
|
||||
@Auth()
|
||||
async changeEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string
|
||||
) {
|
||||
validators.assertValidEmail(email);
|
||||
// @see [sendChangeEmail]
|
||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
const key = await this.session.get(token);
|
||||
if (!key) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
email = decodeURIComponent(email);
|
||||
// email has set token in `sendVerifyChangeEmail`
|
||||
const [id, email] = key.split(',');
|
||||
if (!id || id !== user.id || !email) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changeEmail(id, email);
|
||||
await this.session.delete(token);
|
||||
|
||||
await this.auth.changeEmail(user.id, email);
|
||||
await this.auth.sendNotificationChangeEmail(email);
|
||||
|
||||
return user;
|
||||
@@ -207,29 +196,19 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangePasswordEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
// @deprecated
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
user.id
|
||||
);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -240,27 +219,19 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendSetPasswordEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(
|
||||
TokenType.ChangePassword,
|
||||
user.id
|
||||
);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendSetPasswordEmail(
|
||||
user.email,
|
||||
url.toString()
|
||||
);
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -278,22 +249,19 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendChangeEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string,
|
||||
// @deprecated
|
||||
@Args('email', { nullable: true }) _email?: string
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
if (!user.emailVerified) {
|
||||
throw new ForbiddenException('Please verify your email first.');
|
||||
}
|
||||
|
||||
const token = await this.token.createToken(TokenType.ChangeEmail, user.id);
|
||||
const token = nanoid();
|
||||
await this.session.set(token, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendChangeEmail(user.email, url.toString());
|
||||
const res = await this.auth.sendChangeEmail(email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@@ -304,92 +272,34 @@ export class AuthResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
@Auth()
|
||||
async sendVerifyChangeEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('token') token: string,
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
validators.assertValidEmail(email);
|
||||
const valid = await this.token.verifyToken(TokenType.ChangeEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const hasRegistered = await this.auth.getUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
if (hasRegistered.id !== user.id) {
|
||||
throw new BadRequestException(`The email provided has been taken.`);
|
||||
} else {
|
||||
throw new BadRequestException(
|
||||
`The email provided is the same as the current email.`
|
||||
);
|
||||
}
|
||||
throw new BadRequestException(`Invalid user email`);
|
||||
}
|
||||
|
||||
const verifyEmailToken = await this.token.createToken(
|
||||
TokenType.VerifyEmail,
|
||||
user.id
|
||||
);
|
||||
const withEmailToken = nanoid();
|
||||
await this.session.set(withEmailToken, `${user.id},${email}`);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', verifyEmailToken);
|
||||
url.searchParams.set('email', email);
|
||||
url.searchParams.set('token', withEmailToken);
|
||||
|
||||
const res = await this.auth.sendVerifyChangeEmail(email, url.toString());
|
||||
|
||||
await this.session.delete(token);
|
||||
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
async sendVerifyEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const token = await this.token.createToken(TokenType.VerifyEmail, user.id);
|
||||
|
||||
const url = new URL(callbackUrl, this.config.baseUrl);
|
||||
url.searchParams.set('token', token);
|
||||
|
||||
const res = await this.auth.sendVerifyEmail(user.email, url.toString());
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 5,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
async verifyEmail(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('token') token: string
|
||||
) {
|
||||
if (!token) {
|
||||
throw new BadRequestException('Invalid token');
|
||||
}
|
||||
|
||||
const valid = await this.token.verifyToken(TokenType.VerifyEmail, token, {
|
||||
credential: user.id,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
const { emailVerifiedAt } = await this.auth.setEmailVerified(user.id);
|
||||
|
||||
return emailVerifiedAt !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,333 +1,282 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotAcceptableException,
|
||||
NotFoundException,
|
||||
OnApplicationBootstrap,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, omit } from 'lodash-es';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import type { User } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
Config,
|
||||
CryptoHelper,
|
||||
MailService,
|
||||
SessionCache,
|
||||
PrismaService,
|
||||
verifyChallengeResponse,
|
||||
} from '../../fundamentals';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { UserService } from '../user/service';
|
||||
import type { CurrentUser } from './current-user';
|
||||
import { Quota_FreePlanV1_1 } from '../quota';
|
||||
|
||||
export function parseAuthUserSeqNum(value: any) {
|
||||
switch (typeof value) {
|
||||
case 'number': {
|
||||
return value;
|
||||
}
|
||||
case 'string': {
|
||||
value = Number.parseInt(value);
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
export type UserClaim = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
|
||||
> & {
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
|
||||
default: {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionUser(
|
||||
user: Pick<
|
||||
User,
|
||||
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
|
||||
> & { password?: string | null }
|
||||
): CurrentUser {
|
||||
return assign(
|
||||
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
|
||||
{
|
||||
hasPassword: user.password !== null,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
}
|
||||
);
|
||||
}
|
||||
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnApplicationBootstrap {
|
||||
readonly cookieOptions: CookieOptions = {
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
domain: this.config.host,
|
||||
secure: this.config.https,
|
||||
};
|
||||
static readonly sessionCookieName = 'sid';
|
||||
static readonly authUserSeqHeaderName = 'x-auth-user';
|
||||
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly mailer: MailService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly user: UserService,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly cache: SessionCache
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailer: MailService
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (this.config.node.dev) {
|
||||
await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => {
|
||||
// ignore
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
canSignIn(email: string) {
|
||||
return this.feature.canEarlyAccess(email);
|
||||
}
|
||||
|
||||
async signUp(
|
||||
name: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<CurrentUser> {
|
||||
const user = await this.getUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email was taken');
|
||||
}
|
||||
|
||||
const hashedPassword = await this.crypto.encryptPassword(password);
|
||||
|
||||
return this.user
|
||||
.createUser({
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
})
|
||||
.then(sessionUser);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const user = await this.user.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User Not Found');
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new NotAcceptableException(
|
||||
'User Password is not set. Should login throw email link.'
|
||||
);
|
||||
}
|
||||
|
||||
const passwordMatches = await this.crypto.verifyPassword(
|
||||
password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new NotAcceptableException('Incorrect Password');
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async getUserWithCache(token: string, seq = 0) {
|
||||
const cacheKey = `session:${token}:${seq}`;
|
||||
let user = await this.cache.get<CurrentUser | null>(cacheKey);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
user = await this.getUser(token, seq);
|
||||
|
||||
if (user) {
|
||||
await this.cache.set(cacheKey, user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
// no such session
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userSession = session.userSessions.at(seq);
|
||||
|
||||
// no such user session
|
||||
if (!userSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// user session expired
|
||||
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
where: { id: userSession.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
async getUserList(token: string) {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
if (!session || !session.userSessions.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const users = await this.db.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: session.userSessions.map(({ userId }) => userId),
|
||||
sign(user: UserClaim) {
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + this.config.auth.accessTokenExpiresIn,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
refresh(user: UserClaim) {
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
exp: now + this.config.auth.refreshTokenExpiresIn,
|
||||
iat: now,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async verify(token: string) {
|
||||
try {
|
||||
const data = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
aud: ['https://affine.pro'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
|
||||
return {
|
||||
...data,
|
||||
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
|
||||
createdAt: new Date(data.createdAt),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCaptchaToken(token: any, ip: string) {
|
||||
if (typeof token !== 'string' || !token) return false;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('secret', this.config.auth.captcha.turnstile.secret);
|
||||
formData.append('response', token);
|
||||
formData.append('remoteip', ip);
|
||||
// prevent replay attack
|
||||
formData.append('idempotency_key', nanoid());
|
||||
|
||||
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome = await result.json();
|
||||
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(this.config.node.dev || outcome.hostname === this.config.host)
|
||||
);
|
||||
}
|
||||
|
||||
async verifyChallengeResponse(response: any, resource: string) {
|
||||
return verifyChallengeResponse(
|
||||
response,
|
||||
this.config.auth.captcha.challenge.bits,
|
||||
resource
|
||||
);
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO(@forehalo): need to separate expired session, same for [getUser]
|
||||
// Session
|
||||
// | { user: LimitedUser { email, avatarUrl }, expired: true }
|
||||
// | { user: User, expired: false }
|
||||
return users.map(sessionUser);
|
||||
}
|
||||
|
||||
async signOut(token: string, seq = 0) {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
if (session) {
|
||||
// overflow the logged in user
|
||||
if (session.userSessions.length <= seq) {
|
||||
return session;
|
||||
}
|
||||
|
||||
await this.db.userSession.deleteMany({
|
||||
where: { id: session.userSessions[seq].id },
|
||||
});
|
||||
|
||||
// no more user session active, delete the whole session
|
||||
if (session.userSessions.length === 1) {
|
||||
await this.db.session.delete({ where: { id: session.id } });
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSession(token: string) {
|
||||
return this.db.$transaction(async tx => {
|
||||
const session = await tx.session.findUnique({
|
||||
where: {
|
||||
id: token,
|
||||
},
|
||||
include: {
|
||||
userSessions: {
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.expiresAt && session.expiresAt <= new Date()) {
|
||||
await tx.session.delete({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
async createUserSession(
|
||||
user: { id: string },
|
||||
existingSession?: string,
|
||||
ttl = this.config.auth.session.ttl
|
||||
) {
|
||||
const session = existingSession
|
||||
? await this.getSession(existingSession)
|
||||
: null;
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
if (session) {
|
||||
return this.db.userSession.upsert({
|
||||
where: {
|
||||
sessionId_userId: {
|
||||
sessionId: session.id,
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
expiresAt,
|
||||
},
|
||||
create: {
|
||||
sessionId: session.id,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return this.db.userSession.create({
|
||||
data: {
|
||||
expiresAt,
|
||||
session: {
|
||||
create: {},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setCookie(req: Request, res: Response, user: { id: string }) {
|
||||
const session = await this.createUserSession(
|
||||
user,
|
||||
req.cookies[AuthService.sessionCookieName]
|
||||
);
|
||||
|
||||
res.cookie(AuthService.sessionCookieName, session.sessionId, {
|
||||
expires: session.expiresAt ?? void 0,
|
||||
...this.cookieOptions,
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string) {
|
||||
return this.user.findUserByEmail(email);
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await this.crypto.encryptPassword(newPassword);
|
||||
if (!user.password) {
|
||||
throw new BadRequestException('User has no password');
|
||||
}
|
||||
let equal = false;
|
||||
try {
|
||||
equal = await verify(user.password, password);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new InternalServerErrorException(e, 'Verify password failed');
|
||||
}
|
||||
if (!equal) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
return this.db.user.update({
|
||||
return user;
|
||||
}
|
||||
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
// TODO(@forehalo): handle in event system
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by api sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAnonymousUser(email: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name: 'Unnamed',
|
||||
email,
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by invite sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isUserHasPassword(email: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
return Boolean(user.password);
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
@@ -338,7 +287,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.db.user.findUnique({
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
@@ -348,27 +297,12 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
return this.db.user.update({
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
email: newEmail,
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setEmailVerified(id: string) {
|
||||
return await this.db.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
emailVerifiedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
emailVerifiedAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -385,20 +319,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async sendVerifyChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyChangeEmail(email, callbackUrl);
|
||||
}
|
||||
async sendVerifyEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendVerifyEmail(email, callbackUrl);
|
||||
}
|
||||
async sendNotificationChangeEmail(email: string) {
|
||||
return this.mailer.sendNotificationChangeEmail(email);
|
||||
}
|
||||
|
||||
async sendSignInEmail(email: string, link: string, signUp: boolean) {
|
||||
return signUp
|
||||
? await this.mailer.sendSignUpMail(link.toString(), {
|
||||
to: email,
|
||||
})
|
||||
: await this.mailer.sendSignInMail(link.toString(), {
|
||||
to: email,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { CryptoHelper } from '../../fundamentals/helpers';
|
||||
|
||||
export enum TokenType {
|
||||
SignIn,
|
||||
VerifyEmail,
|
||||
ChangeEmail,
|
||||
ChangePassword,
|
||||
Challenge,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly crypto: CryptoHelper
|
||||
) {}
|
||||
|
||||
async createToken(
|
||||
type: TokenType,
|
||||
credential?: string,
|
||||
ttlInSec: number = 30 * 60
|
||||
) {
|
||||
const plaintextToken = randomUUID();
|
||||
|
||||
const { token } = await this.db.verificationToken.create({
|
||||
data: {
|
||||
type,
|
||||
token: plaintextToken,
|
||||
credential,
|
||||
expiresAt: new Date(Date.now() + ttlInSec * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return this.crypto.encrypt(token);
|
||||
}
|
||||
|
||||
async verifyToken(
|
||||
type: TokenType,
|
||||
token: string,
|
||||
{
|
||||
credential,
|
||||
keep,
|
||||
}: {
|
||||
credential?: string;
|
||||
keep?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
token = this.crypto.decrypt(token);
|
||||
const record = await this.db.verificationToken.findUnique({
|
||||
where: {
|
||||
type_token: {
|
||||
token,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expired = record.expiresAt <= new Date();
|
||||
const valid =
|
||||
!expired && (!record.credential || record.credential === credential);
|
||||
|
||||
if ((expired || valid) && !keep) {
|
||||
await this.db.verificationToken.delete({
|
||||
where: {
|
||||
type_token: {
|
||||
token,
|
||||
type,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return valid ? record : null;
|
||||
}
|
||||
}
|
||||
3
packages/backend/server/src/core/auth/utils/index.ts
Normal file
3
packages/backend/server/src/core/auth/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { jwtDecode as decode, jwtEncode as encode } from './jwt';
|
||||
export { sendVerificationRequest } from './send-mail';
|
||||
export type { SendVerificationRequestParams } from 'next-auth/providers/email';
|
||||
75
packages/backend/server/src/core/auth/utils/jwt.ts
Normal file
75
packages/backend/server/src/core/auth/utils/jwt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
|
||||
import { Config, PrismaService } from '../../../fundamentals';
|
||||
import { getUtcTimestamp, UserClaim } from '../service';
|
||||
|
||||
export const jwtEncode = async (
|
||||
config: Config,
|
||||
prisma: PrismaService,
|
||||
token: JWT | undefined,
|
||||
maxAge: number | undefined
|
||||
) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
picture: user.avatarUrl,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
hasPassword: Boolean(user.password),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const jwtDecode = async (config: Config, token: string | undefined) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, emailVerified, id, picture, hasPassword } = (
|
||||
await jwtVerify(token, config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [config.serverId],
|
||||
leeway: config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as Omit<UserClaim, 'avatarUrl'> & {
|
||||
picture: string | undefined;
|
||||
};
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
picture,
|
||||
sub: id,
|
||||
id,
|
||||
hasPassword,
|
||||
};
|
||||
};
|
||||
38
packages/backend/server/src/core/auth/utils/send-mail.ts
Normal file
38
packages/backend/server/src/core/auth/utils/send-mail.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
|
||||
|
||||
import { Config, MailService, SessionService } from '../../../fundamentals';
|
||||
|
||||
export async function sendVerificationRequest(
|
||||
config: Config,
|
||||
logger: Logger,
|
||||
mailer: MailService,
|
||||
session: SessionService,
|
||||
params: SendVerificationRequestParams
|
||||
) {
|
||||
const { identifier, url } = params;
|
||||
const urlWithToken = new URL(url);
|
||||
const callbackUrl = urlWithToken.searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
} else {
|
||||
const newCallbackUrl = new URL(callbackUrl, config.origin);
|
||||
|
||||
const token = nanoid();
|
||||
await session.set(token, identifier);
|
||||
newCallbackUrl.searchParams.set('token', token);
|
||||
|
||||
urlWithToken.searchParams.set('callbackUrl', newCallbackUrl.toString());
|
||||
}
|
||||
|
||||
const result = await mailer.sendSignInEmail(urlWithToken.toString(), {
|
||||
to: identifier,
|
||||
});
|
||||
logger.log(`send verification email success: ${result.accepted.join(', ')}`);
|
||||
|
||||
const failed = result.rejected.concat(result.pending).filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../fundamentals';
|
||||
import { Public } from './auth';
|
||||
|
||||
export enum ServerFeature {
|
||||
Payment = 'payment',
|
||||
OAuth = 'oauth',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
@@ -17,9 +15,9 @@ registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
|
||||
const ENABLED_FEATURES: ServerFeature[] = [];
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.add(feature);
|
||||
ENABLED_FEATURES.push(feature);
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -48,9 +46,7 @@ export class ServerConfigType {
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
}
|
||||
|
||||
export class ServerConfigResolver {
|
||||
@Public()
|
||||
@Query(() => ServerConfigType, {
|
||||
description: 'server config',
|
||||
})
|
||||
@@ -64,7 +60,7 @@ export class ServerConfigResolver {
|
||||
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||
// this field should be removed after frontend feature flags implemented
|
||||
flavor: AFFiNE.type,
|
||||
features: Array.from(ENABLED_FEATURES),
|
||||
features: ENABLED_FEATURES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
Config,
|
||||
type EventPayload,
|
||||
metrics,
|
||||
OnEvent,
|
||||
PrismaService,
|
||||
} from '../../fundamentals';
|
||||
import { QuotaService } from '../quota';
|
||||
import { Permission } from '../workspaces/types';
|
||||
@@ -19,7 +19,7 @@ export class DocHistoryManager {
|
||||
private readonly logger = new Logger(DocHistoryManager.name);
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly db: PrismaClient,
|
||||
private readonly db: PrismaService,
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient, Snapshot, Update } from '@prisma/client';
|
||||
import { Snapshot, Update } from '@prisma/client';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { defer, retry } from 'rxjs';
|
||||
import {
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
mergeUpdatesInApplyWay as jwstMergeUpdates,
|
||||
metrics,
|
||||
OnEvent,
|
||||
PrismaService,
|
||||
} from '../../fundamentals';
|
||||
|
||||
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
@@ -71,7 +72,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly db: PrismaService,
|
||||
private readonly config: Config,
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventEmitter
|
||||
@@ -229,12 +230,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
update: Buffer,
|
||||
retryTimes = 10
|
||||
) {
|
||||
const timestamp = await new Promise<number>((resolve, reject) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid);
|
||||
const { createdAt } = await this.db.update.create({
|
||||
await this.db.update.create({
|
||||
select: {
|
||||
createdAt: true,
|
||||
seq: true,
|
||||
},
|
||||
data: {
|
||||
workspaceId,
|
||||
@@ -243,27 +244,23 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
blob: update,
|
||||
},
|
||||
});
|
||||
|
||||
return createdAt.getTime();
|
||||
})
|
||||
.pipe(retry(retryTimes)) // retry until seq num not conflict
|
||||
.subscribe({
|
||||
next: timestamp => {
|
||||
next: () => {
|
||||
this.logger.debug(
|
||||
`pushed 1 update for ${guid} in workspace ${workspaceId}`
|
||||
);
|
||||
resolve(timestamp);
|
||||
resolve();
|
||||
},
|
||||
error: e => {
|
||||
this.logger.error('Failed to push updates', e);
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
}).then(() => {
|
||||
return this.updateCachedUpdatesCount(workspaceId, guid, 1);
|
||||
});
|
||||
|
||||
await this.updateCachedUpdatesCount(workspaceId, guid, 1);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
async batchPush(
|
||||
@@ -272,34 +269,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
updates: Buffer[],
|
||||
retryTimes = 10
|
||||
) {
|
||||
const lastSeq = await this.getUpdateSeq(workspaceId, guid, updates.length);
|
||||
const now = Date.now();
|
||||
let timestamp = now;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid, updates.length);
|
||||
let turn = 0;
|
||||
const batchCount = 10;
|
||||
for (const batch of chunk(updates, batchCount)) {
|
||||
await this.db.update.createMany({
|
||||
data: batch.map((update, i) => {
|
||||
const subSeq = turn * batchCount + i + 1;
|
||||
data: batch.map((update, i) => ({
|
||||
workspaceId,
|
||||
id: guid,
|
||||
// `seq` is the last seq num of the batch
|
||||
// example for 11 batched updates, start from seq num 20
|
||||
// seq for first update in the batch should be:
|
||||
// 31 - 11 + subSeq(0 * 10 + 0 + 1) = 21
|
||||
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
|
||||
const seq = lastSeq - updates.length + subSeq;
|
||||
const createdAt = now + subSeq;
|
||||
timestamp = Math.max(timestamp, createdAt);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
blob: update,
|
||||
seq,
|
||||
createdAt: new Date(createdAt), // make sure the updates can be ordered by create time
|
||||
};
|
||||
}),
|
||||
// 31 - 11 + 0 * 10 + 0 + 1 = 21
|
||||
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
|
||||
seq: seq - updates.length + turn * batchCount + i + 1,
|
||||
blob: update,
|
||||
})),
|
||||
});
|
||||
turn++;
|
||||
}
|
||||
@@ -317,56 +304,9 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
}).then(() => {
|
||||
return this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
|
||||
});
|
||||
await this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest timestamp of all docs in the workspace.
|
||||
*/
|
||||
@CallTimer('doc', 'get_stats')
|
||||
async getStats(workspaceId: string, after: number | undefined = 0) {
|
||||
const snapshots = await this.db.snapshot.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
updatedAt: {
|
||||
gt: new Date(after),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updates = await this.db.update.groupBy({
|
||||
where: {
|
||||
workspaceId,
|
||||
createdAt: {
|
||||
gt: new Date(after),
|
||||
},
|
||||
},
|
||||
by: ['id'],
|
||||
_max: {
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
snapshots.forEach(s => {
|
||||
result[s.id] = s.updatedAt.getTime();
|
||||
});
|
||||
|
||||
updates.forEach(u => {
|
||||
if (u._max.createdAt) {
|
||||
result[u.id] = u._max.createdAt.getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PrismaTransaction } from '../../fundamentals';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Feature, FeatureSchema, FeatureType } from './types';
|
||||
|
||||
class FeatureConfig {
|
||||
@@ -66,7 +67,7 @@ export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
||||
|
||||
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
|
||||
|
||||
export async function getFeature(prisma: PrismaTransaction, featureId: number) {
|
||||
export async function getFeature(prisma: PrismaClient, featureId: number) {
|
||||
const cachedQuota = FeatureCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../fundamentals';
|
||||
import { Config, PrismaService } from '../../fundamentals';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
@@ -13,7 +12,7 @@ export class FeatureManagementService {
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@@ -51,10 +50,7 @@ export class FeatureManagementService {
|
||||
async isEarlyAccessUser(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
@@ -115,10 +111,4 @@ export class FeatureManagementService {
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
|
||||
async getUserFeatures(userId: string): Promise<FeatureType[]> {
|
||||
return (await this.feature.getUserFeatures(userId)).map(
|
||||
f => f.feature.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../fundamentals';
|
||||
import { UserType } from '../users/types';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getFeaturesVersion() {
|
||||
const features = await this.prisma.features.findMany({
|
||||
@@ -157,7 +158,7 @@ export class FeatureService {
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType) {
|
||||
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
|
||||
return this.prisma.userFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
@@ -174,7 +175,7 @@ export class FeatureService {
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaTransaction } from '../../fundamentals';
|
||||
import { PrismaService } from '../../fundamentals';
|
||||
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
|
||||
|
||||
const QuotaCache = new Map<number, QuotaConfig>();
|
||||
@@ -6,14 +6,14 @@ const QuotaCache = new Map<number, QuotaConfig>();
|
||||
export class QuotaConfig {
|
||||
readonly config: Quota;
|
||||
|
||||
static async get(tx: PrismaTransaction, featureId: number) {
|
||||
static async get(prisma: PrismaService, featureId: number) {
|
||||
const cachedQuota = QuotaCache.get(featureId);
|
||||
|
||||
if (cachedQuota) {
|
||||
return cachedQuota;
|
||||
}
|
||||
|
||||
const quota = await tx.features.findFirst({
|
||||
const quota = await prisma.features.findFirst({
|
||||
where: {
|
||||
id: featureId,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeatureKind } from '../features/types';
|
||||
import { FeatureKind } from '../features';
|
||||
import { OneDay, OneGB, OneMB } from './constant';
|
||||
import { Quota, QuotaType } from './types';
|
||||
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import {
|
||||
type EventPayload,
|
||||
OnEvent,
|
||||
PrismaTransaction,
|
||||
} from '../../fundamentals';
|
||||
import { type EventPayload, OnEvent, PrismaService } from '../../fundamentals';
|
||||
import { FeatureKind } from '../features';
|
||||
import { QuotaConfig } from './quota';
|
||||
import { QuotaType } from './types';
|
||||
|
||||
type Transaction = Parameters<Parameters<PrismaService['$transaction']>[0]>[0];
|
||||
|
||||
@Injectable()
|
||||
export class QuotaService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// get activated user quota
|
||||
async getUserQuota(userId: string) {
|
||||
@@ -142,8 +139,8 @@ export class QuotaService {
|
||||
});
|
||||
}
|
||||
|
||||
async hasQuota(userId: string, quota: QuotaType, tx?: PrismaTransaction) {
|
||||
const executor = tx ?? this.prisma;
|
||||
async hasQuota(userId: string, quota: QuotaType, transaction?: Transaction) {
|
||||
const executor = transaction ?? this.prisma;
|
||||
|
||||
return executor.userFeatures
|
||||
.count({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { FeatureService, FeatureType } from '../features';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
@@ -11,8 +11,6 @@ type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
|
||||
|
||||
@Injectable()
|
||||
export class QuotaManagementService {
|
||||
protected logger = new Logger(QuotaManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly quota: QuotaService,
|
||||
@@ -40,22 +38,11 @@ export class QuotaManagementService {
|
||||
async getUserUsage(userId: string) {
|
||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||
|
||||
const sizes = await Promise.allSettled(
|
||||
const sizes = await Promise.all(
|
||||
workspaces.map(workspace => this.storage.totalSize(workspace))
|
||||
);
|
||||
|
||||
return sizes.reduce((total, size) => {
|
||||
if (size.status === 'fulfilled') {
|
||||
if (Number.isSafeInteger(size.value)) {
|
||||
return total + size.value;
|
||||
} else {
|
||||
this.logger.error(`Workspace size is invalid: ${size.value}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.error(`Failed to get workspace size: ${size.reason}`);
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
return sizes.reduce((total, size) => total + size, 0);
|
||||
}
|
||||
|
||||
// get workspace's owner quota and total size of used
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commonFeatureSchema, FeatureKind } from '../features/types';
|
||||
import { commonFeatureSchema, FeatureKind } from '../features';
|
||||
import { ByteUnit, OneDay, OneKB } from './constant';
|
||||
|
||||
/// ======== quota define ========
|
||||
|
||||
@@ -6,18 +6,15 @@ import type {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
|
||||
import { Config, createStorageProvider, OnEvent } from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class AvatarStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
private readonly storageConfig: Config['storage']['storages']['avatar'];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
) {
|
||||
this.provider = this.storageFactory.create('avatar');
|
||||
constructor(private readonly config: Config) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'avatar');
|
||||
this.storageConfig = this.config.storage.storages.avatar;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import type {
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
createStorageProvider,
|
||||
EventEmitter,
|
||||
OnEvent,
|
||||
StorageProviderFactory,
|
||||
} from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
@@ -17,9 +18,9 @@ export class WorkspaceBlobStorage {
|
||||
|
||||
constructor(
|
||||
private readonly event: EventEmitter,
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
private readonly config: Config
|
||||
) {
|
||||
this.provider = this.storageFactory.create('blob');
|
||||
this.provider = createStorageProvider(this.config.storage, 'blob');
|
||||
}
|
||||
|
||||
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export enum EventErrorCode {
|
||||
enum EventErrorCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
|
||||
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
import { CallTimer, metrics } from '../../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocManager } from '../../doc';
|
||||
import { UserType } from '../../users';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService } from '../../workspaces/permission';
|
||||
import { Permission } from '../../workspaces/types';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
AccessDeniedError,
|
||||
DocNotFoundError,
|
||||
EventError,
|
||||
EventErrorCode,
|
||||
InternalError,
|
||||
NotInWorkspaceError,
|
||||
} from './error';
|
||||
@@ -38,21 +38,26 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = async function (...args: any[]) {
|
||||
desc.value = function (...args: any[]) {
|
||||
let result: any;
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
result = originalMethod.apply(this, args);
|
||||
} catch (e) {
|
||||
if (e instanceof EventError) {
|
||||
return {
|
||||
error: e,
|
||||
};
|
||||
} else {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch(e => {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
new Logger('EventsGateway').error(e, (e as Error).stack);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
error: new InternalError(e),
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,16 +84,8 @@ type EventResponse<Data = any> =
|
||||
data: Data;
|
||||
});
|
||||
|
||||
function Sync(workspaceId: string): `${string}:sync` {
|
||||
return `${workspaceId}:sync`;
|
||||
}
|
||||
|
||||
function Awareness(workspaceId: string): `${string}:awareness` {
|
||||
return `${workspaceId}:awareness`;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: !AFFiNE.node.prod,
|
||||
cors: process.env.NODE_ENV !== 'production',
|
||||
transports: ['websocket'],
|
||||
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
|
||||
maxHttpBufferSize: 1e8, // 100 MB
|
||||
@@ -115,107 +112,89 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
assertVersion(client: Socket, version?: string) {
|
||||
if (
|
||||
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
||||
AFFiNE.featureFlags.syncClientVersionCheck &&
|
||||
version !== AFFiNE.version
|
||||
) {
|
||||
client.emit('server-version-rejected', {
|
||||
currentVersion: version,
|
||||
requiredVersion: AFFiNE.version,
|
||||
reason: `Client version${
|
||||
version ? ` ${version}` : ''
|
||||
} is outdated, please update to ${AFFiNE.version}`,
|
||||
});
|
||||
|
||||
throw new EventError(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async joinWorkspace(
|
||||
client: Socket,
|
||||
room: `${string}:${'sync' | 'awareness'}`
|
||||
) {
|
||||
await client.join(room);
|
||||
}
|
||||
|
||||
async leaveWorkspace(
|
||||
client: Socket,
|
||||
room: `${string}:${'sync' | 'awareness'}`
|
||||
) {
|
||||
await client.leave(room);
|
||||
}
|
||||
|
||||
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
||||
if (!client.rooms.has(room)) {
|
||||
throw new NotInWorkspaceError(room);
|
||||
}
|
||||
}
|
||||
|
||||
async assertWorkspaceAccessible(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
if (
|
||||
!(await this.permissions.isWorkspaceMember(
|
||||
workspaceId,
|
||||
userId,
|
||||
permission
|
||||
))
|
||||
) {
|
||||
throw new AccessDeniedError(workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake-sync')
|
||||
async handleClientHandshakeSync(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
await this.joinWorkspace(client, Sync(workspaceId));
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
if (canWrite) {
|
||||
await client.join(`${workspaceId}:sync`);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake-awareness')
|
||||
async handleClientHandshakeAwareness(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
this.assertVersion(client, version);
|
||||
await this.assertWorkspaceAccessible(
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
await this.joinWorkspace(client, Awareness(workspaceId));
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
if (canWrite) {
|
||||
await client.join(`${workspaceId}:awareness`);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `client-handshake-sync` and `client-handshake-awareness` instead
|
||||
*/
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake')
|
||||
async handleClientHandShake(
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
if (canWrite) {
|
||||
await client.join([`${workspaceId}:sync`, `${workspaceId}:awareness`]);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave-sync')
|
||||
@@ -223,9 +202,14 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
await this.leaveWorkspace(client, Sync(workspaceId));
|
||||
return {};
|
||||
if (client.rooms.has(`${workspaceId}:sync`)) {
|
||||
await client.leave(`${workspaceId}:sync`);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave-awareness')
|
||||
@@ -233,23 +217,125 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
await this.leaveWorkspace(client, Awareness(workspaceId));
|
||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
||||
await client.leave(`${workspaceId}:awareness`);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `client-leave-sync` and `client-leave-awareness` instead
|
||||
*/
|
||||
@SubscribeMessage('client-leave')
|
||||
async handleClientLeave(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(`${workspaceId}:sync`)) {
|
||||
await client.leave(`${workspaceId}:sync`);
|
||||
}
|
||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
||||
await client.leave(`${workspaceId}:awareness`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-pre-sync')
|
||||
async loadDocStats(
|
||||
@ConnectedSocket() client: Socket,
|
||||
/**
|
||||
* This is the old version of the `client-update` event without any data protocol.
|
||||
* It only exists for backwards compatibility to adapt older clients.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@SubscribeMessage('client-update')
|
||||
async handleClientUpdateV1(
|
||||
@MessageBody()
|
||||
{ workspaceId, timestamp }: { workspaceId: string; timestamp?: number }
|
||||
): Promise<EventResponse<Record<string, number>>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
{
|
||||
workspaceId,
|
||||
guid,
|
||||
update,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
update: string;
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
this.logger.verbose(
|
||||
`Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await this.docManager.getStats(workspaceId, timestamp);
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
|
||||
client
|
||||
.to(`${docId.workspace}:sync`)
|
||||
.emit('server-update', { workspaceId, guid, update });
|
||||
|
||||
// broadcast to all clients with newer version that only listen to `server-updates`
|
||||
client
|
||||
.to(`${docId.workspace}:sync`)
|
||||
.emit('server-updates', { workspaceId, guid, updates: [update] });
|
||||
|
||||
const buf = Buffer.from(update, 'base64');
|
||||
await this.docManager.push(docId.workspace, docId.guid, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the old version of the `doc-load` event without any data protocol.
|
||||
* It only exists for backwards compatibility to adapt older clients.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Auth()
|
||||
@SubscribeMessage('doc-load')
|
||||
async loadDocV1(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
guid,
|
||||
stateVector,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<{ missing: string; state?: string } | false> {
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
const canRead = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id
|
||||
);
|
||||
if (!canRead) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const missing = Buffer.from(
|
||||
encodeStateAsUpdate(
|
||||
doc,
|
||||
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
||||
)
|
||||
).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||
|
||||
return {
|
||||
data: stats,
|
||||
missing,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,32 +352,33 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
updates: string[];
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
): Promise<EventResponse<{ accepted: true }>> {
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
||||
const timestamp = await this.docManager.batchPush(
|
||||
docId.workspace,
|
||||
docId.guid,
|
||||
buffers
|
||||
);
|
||||
|
||||
client
|
||||
.to(Sync(workspaceId))
|
||||
.emit('server-updates', { workspaceId, guid, updates, timestamp });
|
||||
.to(`${docId.workspace}:sync`)
|
||||
.emit('server-updates', { workspaceId, guid, updates });
|
||||
|
||||
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
||||
|
||||
await this.docManager.batchPush(docId.workspace, docId.guid, buffers);
|
||||
return {
|
||||
data: {
|
||||
accepted: true,
|
||||
timestamp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('doc-load-v2')
|
||||
async loadDocV2(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
@@ -303,7 +390,17 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
||||
this.assertInWorkspace(client, Sync(workspaceId));
|
||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
||||
const canRead = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id
|
||||
);
|
||||
if (!canRead) {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||
@@ -335,28 +432,34 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
client.to(Awareness(workspaceId)).emit('new-client-awareness-init');
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
||||
client.to(`${workspaceId}:awareness`).emit('new-client-awareness-init');
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-update')
|
||||
async handleHelpGatheringAwareness(
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
awarenessUpdate,
|
||||
}: { workspaceId: string; awarenessUpdate: string },
|
||||
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse> {
|
||||
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||
client
|
||||
.to(Awareness(workspaceId))
|
||||
.emit('server-awareness-broadcast', { workspaceId, awarenessUpdate });
|
||||
return {};
|
||||
if (client.rooms.has(`${message.workspaceId}:awareness`)) {
|
||||
client
|
||||
.to(`${message.workspaceId}:awareness`)
|
||||
.emit('server-awareness-broadcast', message);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(message.workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Quota_FreePlanV1_1 } from '../quota/schema';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
defaultUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
avatarUrl: true,
|
||||
registered: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
get userCreatingData() {
|
||||
return {
|
||||
name: 'Unnamed',
|
||||
features: {
|
||||
create: {
|
||||
reason: 'created by invite sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(data: Prisma.UserCreateInput) {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAnonymousUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.createUser({
|
||||
email,
|
||||
name: email.split('@')[0],
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
select: this.defaultUserSelect,
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: this.defaultUserSelect,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* supposed to be used only for `Credential SignIn`
|
||||
*/
|
||||
async findUserWithHashedPasswordByEmail(email: string) {
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateUser(
|
||||
email: string,
|
||||
data?: Partial<Prisma.UserCreateInput>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
return this.createAnonymousUser(email, data);
|
||||
}
|
||||
|
||||
async fulfillUser(
|
||||
email: string,
|
||||
data: Partial<
|
||||
Pick<Prisma.UserCreateInput, 'emailVerifiedAt' | 'registered'>
|
||||
>
|
||||
) {
|
||||
return this.prisma.user.upsert({
|
||||
select: this.defaultUserSelect,
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
update: data,
|
||||
create: {
|
||||
email,
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,15 @@ import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserManagementResolver } from './management';
|
||||
import { UserResolver } from './resolver';
|
||||
import { UserService } from './service';
|
||||
import { UsersService } from './users';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||
providers: [UserResolver, UserManagementResolver, UserService],
|
||||
providers: [UserResolver, UserManagementResolver, UsersService],
|
||||
controllers: [UserAvatarController],
|
||||
exports: [UserService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UserModule {}
|
||||
export class UsersModule {}
|
||||
|
||||
export { UserService } from './service';
|
||||
export { UserType } from './types';
|
||||
export { UsersService } from './users';
|
||||
@@ -6,21 +6,23 @@ import {
|
||||
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { Auth, CurrentUser } from '../auth/guard';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { UserService } from './service';
|
||||
import { UserType } from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly users: UserService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@@ -32,7 +34,7 @@ export class UserManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
@@ -42,9 +44,7 @@ export class UserManagementResolver {
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
} else {
|
||||
const user = await this.users.createAnonymousUser(email, {
|
||||
registered: false,
|
||||
});
|
||||
const user = await this.auth.createAnonymousUser(email);
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class UserManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
@@ -79,15 +79,13 @@ export class UserManagementResolver {
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() user: CurrentUser
|
||||
@CurrentUser() user: UserType
|
||||
): 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);
|
||||
});
|
||||
return this.feature.listEarlyAccess();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -7,49 +7,79 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient, type User } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
EventEmitter,
|
||||
type FileUpload,
|
||||
PaymentRequiredException,
|
||||
PrismaService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth/current-user';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { QuotaService } from '../quota';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
RemoveAvatar,
|
||||
UpdateUserInput,
|
||||
UserOrLimitedUser,
|
||||
UserQuotaType,
|
||||
UserType,
|
||||
} from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly users: UserService,
|
||||
private readonly users: UsersService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly event: EventEmitter
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Publicable()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
async currentUser(@CurrentUser() user?: UserType) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedUser = await this.users.findUserById(user.id);
|
||||
if (!storedUser) {
|
||||
throw new BadRequestException(`User ${user.id} not found in db`);
|
||||
}
|
||||
return {
|
||||
id: storedUser.id,
|
||||
name: storedUser.name,
|
||||
email: storedUser.email,
|
||||
emailVerified: storedUser.emailVerified,
|
||||
avatarUrl: storedUser.avatarUrl,
|
||||
createdAt: storedUser.createdAt,
|
||||
hasPassword: !!storedUser.password,
|
||||
};
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -63,29 +93,32 @@ export class UserResolver {
|
||||
})
|
||||
@Public()
|
||||
async user(
|
||||
@CurrentUser() currentUser?: CurrentUser,
|
||||
@CurrentUser() currentUser?: UserType,
|
||||
@Args('email') email?: string
|
||||
): Promise<typeof UserOrLimitedUser | null> {
|
||||
) {
|
||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
return new GraphQLError(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYMENT_REQUIRED],
|
||||
code: HttpStatus.PAYMENT_REQUIRED,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserWithHashedPasswordByEmail(email);
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (currentUser) return user;
|
||||
|
||||
// return empty response when user not exists
|
||||
if (!user) return null;
|
||||
|
||||
if (currentUser) {
|
||||
return sessionUser(user);
|
||||
}
|
||||
|
||||
// only return limited info when not logged in
|
||||
return {
|
||||
email: user.email,
|
||||
hasPassword: !!user.password,
|
||||
email: user?.email,
|
||||
hasPassword: !!user?.password,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,21 +135,12 @@ export class UserResolver {
|
||||
name: 'invoiceCount',
|
||||
description: 'Get user invoice count',
|
||||
})
|
||||
async invoiceCount(@CurrentUser() user: CurrentUser) {
|
||||
async invoiceCount(@CurrentUser() user: UserType) {
|
||||
return this.prisma.userInvoice.count({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 10, ttl: 60 } })
|
||||
@ResolveField(() => [FeatureType], {
|
||||
name: 'features',
|
||||
description: 'Enabled features of a user',
|
||||
})
|
||||
async userFeatures(@CurrentUser() user: CurrentUser) {
|
||||
return this.feature.getUserFeatures(user.id);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -128,7 +152,7 @@ export class UserResolver {
|
||||
description: 'Upload user avatar',
|
||||
})
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
@@ -152,33 +176,6 @@ export class UserResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => UserType, {
|
||||
name: 'updateProfile',
|
||||
})
|
||||
async updateUserProfile(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input', { type: () => UpdateUserInput }) input: UpdateUserInput
|
||||
): Promise<UserType> {
|
||||
input = omitBy(input, isNil);
|
||||
|
||||
if (Object.keys(input).length === 0) {
|
||||
return user;
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: input,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
@@ -189,7 +186,7 @@ export class UserResolver {
|
||||
name: 'removeAvatar',
|
||||
description: 'Remove user avatar',
|
||||
})
|
||||
async removeAvatar(@CurrentUser() user: CurrentUser) {
|
||||
async removeAvatar(@CurrentUser() user: UserType) {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
@@ -207,9 +204,7 @@ export class UserResolver {
|
||||
},
|
||||
})
|
||||
@Mutation(() => DeleteAccount)
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<DeleteAccount> {
|
||||
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
|
||||
const deletedUser = await this.users.deleteUser(user.id);
|
||||
this.event.emit('user.deleted', deletedUser);
|
||||
return { success: true };
|
||||
@@ -1,15 +1,7 @@
|
||||
import {
|
||||
createUnionType,
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
ObjectType,
|
||||
} from '@nestjs/graphql';
|
||||
import { createUnionType, Field, ID, 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' })
|
||||
@@ -50,7 +42,7 @@ export class UserQuotaType {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements CurrentUser {
|
||||
export class UserType implements Partial<User> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@@ -61,25 +53,19 @@ export class UserType implements CurrentUser {
|
||||
email!: string;
|
||||
|
||||
@Field(() => String, { description: 'User avatar url', nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
avatarUrl: string | null = null;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User email verified',
|
||||
})
|
||||
emailVerified!: boolean;
|
||||
@Field(() => Date, { description: 'User email verified', nullable: true })
|
||||
emailVerified: Date | null = null;
|
||||
|
||||
@Field({ description: 'User created date', nullable: true })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword!: boolean | null;
|
||||
|
||||
@Field(() => Date, {
|
||||
deprecationReason: 'useless',
|
||||
description: 'User email verified',
|
||||
nullable: true,
|
||||
})
|
||||
createdAt?: Date | null;
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -91,7 +77,7 @@ export class LimitedUserType implements Partial<User> {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword!: boolean | null;
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export const UserOrLimitedUser = createUnionType({
|
||||
@@ -115,9 +101,3 @@ export class RemoveAvatar {
|
||||
@Field()
|
||||
success!: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateUserInput implements Partial<User> {
|
||||
@Field({ description: 'User name', nullable: true })
|
||||
name?: string;
|
||||
}
|
||||
32
packages/backend/server/src/core/users/users.ts
Normal file
32
packages/backend/server/src/core/users/users.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import z from 'zod';
|
||||
|
||||
function getAuthCredentialValidator() {
|
||||
const email = z.string().email({ message: 'Invalid email address' });
|
||||
let password = z.string();
|
||||
|
||||
const minPasswordLength = AFFiNE.node.prod ? 8 : 1;
|
||||
password = password
|
||||
.min(minPasswordLength, {
|
||||
message: `Password must be ${minPasswordLength} or more charactors long`,
|
||||
})
|
||||
.max(20, { message: 'Password must be 20 or fewer charactors long' });
|
||||
|
||||
return z
|
||||
.object({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
.required();
|
||||
}
|
||||
|
||||
function assertValid<T>(z: z.ZodType<T>, value: unknown) {
|
||||
const result = z.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
const firstIssue = result.error.issues.at(0);
|
||||
if (firstIssue) {
|
||||
throw new BadRequestException(firstIssue.message);
|
||||
} else {
|
||||
throw new BadRequestException('Invalid credential');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertValidEmail(email: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.email, email);
|
||||
}
|
||||
|
||||
export function assertValidPassword(password: string) {
|
||||
assertValid(getAuthCredentialValidator().shape.password, password);
|
||||
}
|
||||
|
||||
export function assertValidCredential(credential: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
assertValid(getAuthCredentialValidator(), credential);
|
||||
}
|
||||
|
||||
export const validators = {
|
||||
assertValidEmail,
|
||||
assertValidPassword,
|
||||
assertValidCredential,
|
||||
};
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { CallTimer } from '../../fundamentals';
|
||||
import { CurrentUser, Public } from '../auth';
|
||||
import { CallTimer, PrismaService } from '../../fundamentals';
|
||||
import { Auth, CurrentUser, Publicable } from '../auth';
|
||||
import { DocHistoryManager, DocManager } from '../doc';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { UserType } from '../users';
|
||||
import { DocID } from '../utils/doc';
|
||||
import { PermissionService, PublicPageMode } from './permission';
|
||||
import { Permission } from './types';
|
||||
@@ -26,13 +26,12 @@ export class WorkspacesController {
|
||||
private readonly permission: PermissionService,
|
||||
private readonly docManager: DocManager,
|
||||
private readonly historyManager: DocHistoryManager,
|
||||
private readonly prisma: PrismaClient
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
// get workspace blob
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@Public()
|
||||
@Get('/:id/blobs/:name')
|
||||
@CallTimer('controllers', 'workspace_get_blob')
|
||||
async blob(
|
||||
@@ -62,11 +61,12 @@ export class WorkspacesController {
|
||||
}
|
||||
|
||||
// get doc binary
|
||||
@Public()
|
||||
@Get('/:id/docs/:guid')
|
||||
@Auth()
|
||||
@Publicable()
|
||||
@CallTimer('controllers', 'workspace_get_doc')
|
||||
async doc(
|
||||
@CurrentUser() user: CurrentUser | undefined,
|
||||
@CurrentUser() user: UserType | undefined,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Res() res: Response
|
||||
@@ -111,9 +111,10 @@ export class WorkspacesController {
|
||||
}
|
||||
|
||||
@Get('/:id/docs/:guid/histories/:timestamp')
|
||||
@Auth()
|
||||
@CallTimer('controllers', 'workspace_get_history')
|
||||
async history(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Param('timestamp') timestamp: string,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DocModule } from '../doc';
|
||||
import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserModule } from '../user';
|
||||
import { UsersService } from '../users';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { WorkspaceManagementResolver } from './management';
|
||||
import { PermissionService } from './permission';
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
} from './resolvers';
|
||||
|
||||
@Module({
|
||||
imports: [DocModule, FeatureModule, QuotaModule, StorageModule, UserModule],
|
||||
imports: [DocModule, FeatureModule, QuotaModule, StorageModule],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
WorkspaceResolver,
|
||||
WorkspaceManagementResolver,
|
||||
PermissionService,
|
||||
UsersService,
|
||||
PagePermissionResolver,
|
||||
DocHistoryResolver,
|
||||
WorkspaceBlobResolver,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { CurrentUser } from '../auth';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceType } from './types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceManagementResolver {
|
||||
constructor(
|
||||
@@ -31,7 +33,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addWorkspaceFeature(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<number> {
|
||||
@@ -50,7 +52,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeWorkspaceFeature(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<boolean> {
|
||||
@@ -69,7 +71,7 @@ export class WorkspaceManagementResolver {
|
||||
})
|
||||
@Query(() => [WorkspaceType])
|
||||
async listWorkspaceFeatures(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<WorkspaceType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
@@ -81,7 +83,7 @@ export class WorkspaceManagementResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async setWorkspaceExperimentalFeature(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
|
||||
@Args('enable') enable: boolean
|
||||
@@ -115,7 +117,7 @@ export class WorkspaceManagementResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async availableFeatures(
|
||||
@CurrentUser() user: CurrentUser
|
||||
@CurrentUser() user: UserType
|
||||
): Promise<FeatureType[]> {
|
||||
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
|
||||
if (isEarlyAccessUser) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { type Prisma, PrismaClient } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { PrismaService } from '../../fundamentals';
|
||||
import { Permission } from './types';
|
||||
|
||||
export enum PublicPageMode {
|
||||
@@ -10,7 +11,7 @@ export enum PublicPageMode {
|
||||
|
||||
@Injectable()
|
||||
export class PermissionService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/// Start regin: workspace permission
|
||||
async get(ws: string, user: string) {
|
||||
@@ -73,28 +74,6 @@ export class PermissionService {
|
||||
return this.tryCheckPage(ws, id, user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
||||
*/
|
||||
async isWorkspaceMember(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission
|
||||
): Promise<boolean> {
|
||||
const count = await this.prisma.workspaceUserPermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
gte: permission,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count !== 0;
|
||||
}
|
||||
|
||||
async checkWorkspace(
|
||||
ws: string,
|
||||
user?: string,
|
||||
@@ -320,18 +299,6 @@ export class PermissionService {
|
||||
return this.tryCheckWorkspace(ws, user, permission);
|
||||
}
|
||||
|
||||
async isPublicPage(ws: string, page: string) {
|
||||
return this.prisma.workspacePage
|
||||
.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
public: true,
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
|
||||
async publishPage(ws: string, page: string, mode = PublicPageMode.Page) {
|
||||
return this.prisma.workspacePage.upsert({
|
||||
where: {
|
||||
@@ -354,19 +321,26 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
async revokePublicPage(ws: string, page: string) {
|
||||
return this.prisma.workspacePage.upsert({
|
||||
const workspacePage = await this.prisma.workspacePage.findUnique({
|
||||
where: {
|
||||
workspaceId_pageId: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
public: false,
|
||||
});
|
||||
if (!workspacePage) {
|
||||
throw new Error('Page is not public');
|
||||
}
|
||||
|
||||
return this.prisma.workspacePage.update({
|
||||
where: {
|
||||
workspaceId_pageId: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
data: {
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
PayloadTooLargeException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -13,6 +8,7 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
@@ -22,14 +18,16 @@ import {
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { FeatureManagementService, FeatureType } from '../../features';
|
||||
import { QuotaManagementService } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UserType } from '../../users';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceBlobResolver {
|
||||
logger = new Logger(WorkspaceBlobResolver.name);
|
||||
@@ -45,7 +43,7 @@ export class WorkspaceBlobResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async blobs(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspace.id, user.id);
|
||||
@@ -72,7 +70,7 @@ export class WorkspaceBlobResolver {
|
||||
})
|
||||
@MakeCache(['blobs'], ['workspaceId'])
|
||||
async listBlobs(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||
@@ -88,7 +86,7 @@ export class WorkspaceBlobResolver {
|
||||
@Query(() => WorkspaceBlobSizes, {
|
||||
deprecationReason: 'use `user.storageUsage` instead',
|
||||
})
|
||||
async collectAllBlobSizes(@CurrentUser() user: CurrentUser) {
|
||||
async collectAllBlobSizes(@CurrentUser() user: UserType) {
|
||||
const size = await this.quota.getUserUsage(user.id);
|
||||
return { size };
|
||||
}
|
||||
@@ -100,7 +98,7 @@ export class WorkspaceBlobResolver {
|
||||
deprecationReason: 'no more needed',
|
||||
})
|
||||
async checkBlobSize(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('size', { type: () => SafeIntResolver }) blobSize: number
|
||||
) {
|
||||
@@ -119,7 +117,7 @@ export class WorkspaceBlobResolver {
|
||||
@Mutation(() => String)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async setBlob(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||
blob: FileUpload
|
||||
@@ -140,7 +138,12 @@ export class WorkspaceBlobResolver {
|
||||
|
||||
const checkExceeded = (recvSize: number) => {
|
||||
if (!storageQuota) {
|
||||
throw new ForbiddenException('Cannot find user quota.');
|
||||
throw new GraphQLError('cannot find user quota', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
const total = usedSize + recvSize;
|
||||
// only skip total storage check if workspace has unlimited feature
|
||||
@@ -160,9 +163,12 @@ export class WorkspaceBlobResolver {
|
||||
};
|
||||
|
||||
if (checkExceeded(0)) {
|
||||
throw new PayloadTooLargeException(
|
||||
'Storage or blob size limit exceeded.'
|
||||
);
|
||||
throw new GraphQLError('storage or blob size limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const stream = blob.createReadStream();
|
||||
@@ -174,7 +180,12 @@ export class WorkspaceBlobResolver {
|
||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||
if (checkExceeded(bufferSize)) {
|
||||
reject(
|
||||
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
||||
new GraphQLError('storage or blob size limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -183,7 +194,14 @@ export class WorkspaceBlobResolver {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (checkExceeded(buffer.length)) {
|
||||
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
||||
reject(
|
||||
new GraphQLError('storage limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
resolve(buffer);
|
||||
}
|
||||
@@ -197,7 +215,7 @@ export class WorkspaceBlobResolver {
|
||||
@Mutation(() => Boolean)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async deleteBlob(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('hash') name: string
|
||||
) {
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
import type { SnapshotHistory } from '@prisma/client';
|
||||
|
||||
import { CloudThrottlerGuard } from '../../../fundamentals';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocHistoryManager } from '../../doc';
|
||||
import { UserType } from '../../users';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceType } from '../types';
|
||||
@@ -67,9 +68,10 @@ export class DocHistoryResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Mutation(() => Date)
|
||||
async recoverDoc(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('guid') guid: string,
|
||||
@Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import {
|
||||
PrismaClient,
|
||||
type WorkspacePage as PrismaWorkspacePage,
|
||||
} from '@prisma/client';
|
||||
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
||||
|
||||
import { CloudThrottlerGuard } from '../../../fundamentals';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { CloudThrottlerGuard, PrismaService } from '../../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { UserType } from '../../users';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { PermissionService, PublicPageMode } from '../permission';
|
||||
import { Permission, WorkspaceType } from '../types';
|
||||
@@ -41,10 +39,11 @@ class WorkspacePage implements Partial<PrismaWorkspacePage> {
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class PagePermissionResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
@@ -88,7 +87,7 @@ export class PagePermissionResolver {
|
||||
deprecationReason: 'renamed to publicPage',
|
||||
})
|
||||
async deprecatedSharePage(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
@@ -98,7 +97,7 @@ export class PagePermissionResolver {
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async publishPage(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string,
|
||||
@Args({
|
||||
@@ -112,7 +111,7 @@ export class PagePermissionResolver {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -132,7 +131,7 @@ export class PagePermissionResolver {
|
||||
deprecationReason: 'use revokePublicPage',
|
||||
})
|
||||
async deprecatedRevokePage(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
@@ -142,14 +141,14 @@ export class PagePermissionResolver {
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async revokePublicPage(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -158,15 +157,6 @@ export class PagePermissionResolver {
|
||||
Permission.Read
|
||||
);
|
||||
|
||||
const isPublic = await this.permission.isPublicPage(
|
||||
docId.workspace,
|
||||
docId.guid
|
||||
);
|
||||
|
||||
if (!isPublic) {
|
||||
throw new BadRequestException('Page is not public');
|
||||
}
|
||||
|
||||
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -15,8 +14,9 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { User } from '@prisma/client';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
@@ -25,14 +25,14 @@ import {
|
||||
EventEmitter,
|
||||
type FileUpload,
|
||||
MailService,
|
||||
MutexService,
|
||||
PrismaService,
|
||||
Throttle,
|
||||
TooManyRequestsException,
|
||||
} from '../../../fundamentals';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { Auth, CurrentUser, Public } from '../../auth';
|
||||
import { AuthService } from '../../auth/service';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UserService, UserType } from '../../user';
|
||||
import { UsersService, UserType } from '../../users';
|
||||
import { PermissionService } from '../permission';
|
||||
import {
|
||||
InvitationType,
|
||||
@@ -49,19 +49,20 @@ import { defaultWorkspaceAvatar } from '../utils';
|
||||
* Other rate limit: 120 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly users: UserService,
|
||||
private readonly users: UsersService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly blobStorage: WorkspaceBlobStorage,
|
||||
private readonly mutex: MutexService
|
||||
private readonly blobStorage: WorkspaceBlobStorage
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Permission, {
|
||||
@@ -69,7 +70,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async permission(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
@@ -160,7 +161,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async isOwner(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
|
||||
@@ -172,7 +173,7 @@ export class WorkspaceResolver {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces(@CurrentUser() user: CurrentUser) {
|
||||
async workspaces(@CurrentUser() user: User) {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
@@ -216,7 +217,7 @@ export class WorkspaceResolver {
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
async workspace(@CurrentUser() user: CurrentUser, @Args('id') id: string) {
|
||||
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissions.checkWorkspace(id, user.id);
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
@@ -231,7 +232,7 @@ export class WorkspaceResolver {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
// we no longer support init workspace with a preload file
|
||||
// use sync system to uploading them once created
|
||||
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
|
||||
@@ -289,7 +290,7 @@ export class WorkspaceResolver {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
@@ -304,10 +305,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
) {
|
||||
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissions.checkWorkspace(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
@@ -323,7 +321,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => String)
|
||||
async invite(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('email') email: string,
|
||||
@Args('permission', { type: () => Permission }) permission: Permission,
|
||||
@@ -339,87 +337,83 @@ export class WorkspaceResolver {
|
||||
throw new ForbiddenException('Cannot change owner');
|
||||
}
|
||||
|
||||
try {
|
||||
// lock to prevent concurrent invite
|
||||
const lockFlag = `invite:${workspaceId}`;
|
||||
await using lock = await this.mutex.lock(lockFlag);
|
||||
if (!lock) {
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
}
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
throw new GraphQLError('Workspace member limit reached', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
return new PayloadTooLargeException('Workspace member limit reached.');
|
||||
}
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord = await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.auth.createAnonymousUser(email);
|
||||
}
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord =
|
||||
await this.prisma.workspaceUserPermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.users.createAnonymousUser(email, {
|
||||
registered: false,
|
||||
const inviteId = await this.permissions.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
try {
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const ret = await this.permissions.revokeWorkspace(
|
||||
workspaceId,
|
||||
target.id
|
||||
);
|
||||
|
||||
const inviteId = await this.permissions.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
try {
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const ret = await this.permissions.revokeWorkspace(
|
||||
workspaceId,
|
||||
target.id
|
||||
if (!ret) {
|
||||
this.logger.fatal(
|
||||
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
|
||||
);
|
||||
|
||||
if (!ret) {
|
||||
this.logger.fatal(
|
||||
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||
);
|
||||
}
|
||||
return new InternalServerErrorException(
|
||||
'Failed to send invite email. Please try again.'
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||
);
|
||||
}
|
||||
return new GraphQLError(
|
||||
'failed to send invite email, please try again',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
|
||||
code: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
return inviteId;
|
||||
} catch (e) {
|
||||
this.logger.error('failed to invite user', e);
|
||||
return new TooManyRequestsException('Server is busy');
|
||||
}
|
||||
return inviteId;
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
@@ -488,7 +482,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revoke(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
@@ -532,7 +526,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async leaveWorkspace(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('workspaceName') workspaceName: string,
|
||||
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import type { Workspace } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { UserType } from '../user/types';
|
||||
import { UserType } from '../users/types';
|
||||
|
||||
export enum Permission {
|
||||
Read = 0,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
@@ -25,7 +25,7 @@ export async function collectMigrations(): Promise<Migration[]> {
|
||||
|
||||
const migrations: Migration[] = await Promise.all(
|
||||
migrationFiles.map(async file => {
|
||||
return import(pathToFileURL(file).href).then(mod => {
|
||||
return import(file).then(mod => {
|
||||
const migration = mod[Object.keys(mod)[0]];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { UserService } from '../../core/user';
|
||||
import { Config, CryptoHelper } from '../../fundamentals';
|
||||
import { Config } from '../../fundamentals';
|
||||
|
||||
export class SelfHostAdmin1605053000403 {
|
||||
// do the migration
|
||||
static async up(_db: PrismaClient, ref: ModuleRef) {
|
||||
static async up(db: PrismaClient, ref: ModuleRef) {
|
||||
const config = ref.get(Config, { strict: false });
|
||||
const crypto = ref.get(CryptoHelper, { strict: false });
|
||||
const user = ref.get(UserService, { strict: false });
|
||||
if (config.isSelfhosted) {
|
||||
if (
|
||||
!process.env.AFFINE_ADMIN_EMAIL ||
|
||||
@@ -19,12 +17,13 @@ export class SelfHostAdmin1605053000403 {
|
||||
'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.'
|
||||
);
|
||||
}
|
||||
await user.findOrCreateUser(process.env.AFFINE_ADMIN_EMAIL, {
|
||||
name: 'AFFINE First User',
|
||||
emailVerifiedAt: new Date(),
|
||||
password: await crypto.encryptPassword(
|
||||
process.env.AFFINE_ADMIN_PASSWORD
|
||||
),
|
||||
await db.user.create({
|
||||
data: {
|
||||
name: 'AFFINE First User',
|
||||
email: process.env.AFFINE_ADMIN_EMAIL,
|
||||
emailVerified: new Date(),
|
||||
password: await hash(process.env.AFFINE_ADMIN_PASSWORD),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { loop } from './utils/loop';
|
||||
|
||||
export class Oauth1710319359062 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
await loop(async (skip, take) => {
|
||||
const oldRecords = await db.deprecatedNextAuthAccount.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
providerAccountId: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
await db.connectedAccount.createMany({
|
||||
data: oldRecords.map(record => ({
|
||||
userId: record.userId,
|
||||
provider: record.provider,
|
||||
scope: record.scope,
|
||||
providerAccountId: record.providerAccountId,
|
||||
accessToken: record.access_token,
|
||||
refreshToken: record.refresh_token,
|
||||
expiresAt: record.expires_at
|
||||
? new Date(record.expires_at * 1000)
|
||||
: null,
|
||||
})),
|
||||
});
|
||||
|
||||
return oldRecords.length;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(db: PrismaClient) {
|
||||
await db.connectedAccount.deleteMany({});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export async function loop(
|
||||
batchFn: (skip: number, take: number) => Promise<number>,
|
||||
chunkSize: number = 100
|
||||
) {
|
||||
let turn = 0;
|
||||
let last = chunkSize;
|
||||
|
||||
while (last === chunkSize) {
|
||||
last = await batchFn(chunkSize * turn, chunkSize);
|
||||
|
||||
turn++;
|
||||
}
|
||||
}
|
||||
@@ -87,22 +87,6 @@ export interface AFFiNEConfig {
|
||||
sync: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application secrets for authentication and data encryption
|
||||
*/
|
||||
secrets: {
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment environment
|
||||
*/
|
||||
@@ -190,7 +174,6 @@ export interface AFFiNEConfig {
|
||||
*/
|
||||
featureFlags: {
|
||||
earlyAccessPreview: boolean;
|
||||
syncClientVersionCheck: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -220,32 +203,67 @@ export interface AFFiNEConfig {
|
||||
* authentication config
|
||||
*/
|
||||
auth: {
|
||||
session: {
|
||||
/**
|
||||
* Application auth expiration time in seconds
|
||||
*
|
||||
* @default 15 days
|
||||
*/
|
||||
ttl: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Application access token config
|
||||
* Application access token expiration time
|
||||
*/
|
||||
accessToken: {
|
||||
/**
|
||||
* Application access token expiration time in seconds
|
||||
*
|
||||
* @default 7 days
|
||||
*/
|
||||
ttl: number;
|
||||
/**
|
||||
* Application refresh token expiration time in seconds
|
||||
*
|
||||
* @default 30 days
|
||||
*/
|
||||
refreshTokenTtl: number;
|
||||
};
|
||||
readonly accessTokenExpiresIn: number;
|
||||
/**
|
||||
* Application refresh token expiration time
|
||||
*/
|
||||
readonly refreshTokenExpiresIn: number;
|
||||
/**
|
||||
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
|
||||
* Defaults to 60 if omitted.
|
||||
*/
|
||||
readonly leeway: number;
|
||||
/**
|
||||
* Application public key
|
||||
*
|
||||
*/
|
||||
readonly publicKey: string;
|
||||
/**
|
||||
* Application private key
|
||||
*
|
||||
*/
|
||||
readonly privateKey: string;
|
||||
/**
|
||||
* whether allow user to signup with email directly
|
||||
*/
|
||||
enableSignup: boolean;
|
||||
/**
|
||||
* whether allow user to signup by oauth providers
|
||||
*/
|
||||
enableOauth: boolean;
|
||||
/**
|
||||
* NEXTAUTH_SECRET
|
||||
*/
|
||||
nextAuthSecret: string;
|
||||
/**
|
||||
* all available oauth providers
|
||||
*/
|
||||
oauthProviders: Partial<
|
||||
Record<
|
||||
ExternalAccount,
|
||||
{
|
||||
enabled: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
/**
|
||||
* uri to start oauth flow
|
||||
*/
|
||||
authorizationUri?: string;
|
||||
/**
|
||||
* uri to authenticate `access_token` when user is redirected back from oauth provider with `code`
|
||||
*/
|
||||
accessTokenUri?: string;
|
||||
/**
|
||||
* uri to get user info with authenticated `access_token`
|
||||
*/
|
||||
userInfoUri?: string;
|
||||
args?: Record<string, any>;
|
||||
}
|
||||
>
|
||||
>;
|
||||
captcha: {
|
||||
/**
|
||||
* whether to enable captcha
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
|
||||
import { merge } from 'lodash-es';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
import pkg from '../../../package.json' assert { type: 'json' };
|
||||
import {
|
||||
@@ -22,9 +23,7 @@ AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
|
||||
const ONE_DAY_IN_SEC = 60 * 60 * 24;
|
||||
|
||||
const keyPair = (function () {
|
||||
const jwtKeyPair = (function () {
|
||||
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
@@ -115,13 +114,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
get deploy() {
|
||||
return !this.node.dev && !this.node.test;
|
||||
},
|
||||
secrets: {
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKey: keyPair.publicKey,
|
||||
},
|
||||
featureFlags: {
|
||||
earlyAccessPreview: false,
|
||||
syncClientVersionCheck: false,
|
||||
},
|
||||
https: false,
|
||||
host: 'localhost',
|
||||
@@ -150,13 +144,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
playground: true,
|
||||
},
|
||||
auth: {
|
||||
session: {
|
||||
ttl: 15 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
accessToken: {
|
||||
ttl: 7 * ONE_DAY_IN_SEC,
|
||||
refreshTokenTtl: 30 * ONE_DAY_IN_SEC,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
accessTokenExpiresIn: parse('1h')! / 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||
leeway: 60,
|
||||
captcha: {
|
||||
enable: false,
|
||||
turnstile: {
|
||||
@@ -166,6 +158,14 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
privateKey: jwtKeyPair.privateKey,
|
||||
publicKey: jwtKeyPair.publicKey,
|
||||
enableSignup: true,
|
||||
enableOauth: false,
|
||||
get nextAuthSecret() {
|
||||
return this.privateKey;
|
||||
},
|
||||
oauthProviders: {},
|
||||
},
|
||||
storage: getDefaultAFFiNEStorageConfig(),
|
||||
rateLimiter: {
|
||||
@@ -187,10 +187,10 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
enabled: false,
|
||||
},
|
||||
plugins: {
|
||||
enabled: new Set(),
|
||||
enabled: [],
|
||||
use(plugin, config) {
|
||||
this[plugin] = merge(this[plugin], config || {});
|
||||
this.enabled.add(plugin);
|
||||
this.enabled.push(plugin);
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { S3ClientConfigType } from '@aws-sdk/client-s3';
|
||||
|
||||
export type StorageProviderType = 'fs' | 'r2' | 's3';
|
||||
export interface FsStorageConfig {
|
||||
path: string;
|
||||
}
|
||||
export type R2StorageConfig = S3ClientConfigType & {
|
||||
accountId: string;
|
||||
};
|
||||
export type S3StorageConfig = S3ClientConfigType;
|
||||
|
||||
export interface StorageProvidersConfig {
|
||||
fs: FsStorageConfig;
|
||||
}
|
||||
|
||||
export type StorageProviderType = keyof StorageProvidersConfig;
|
||||
|
||||
export type StorageConfig<Ext = unknown> = {
|
||||
export type StorageTargetConfig<Ext = unknown> = {
|
||||
provider: StorageProviderType;
|
||||
bucket: string;
|
||||
} & Ext;
|
||||
|
||||
export interface StoragesConfig {
|
||||
avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>;
|
||||
blob: StorageConfig;
|
||||
}
|
||||
|
||||
export interface AFFiNEStorageConfig {
|
||||
/**
|
||||
* All providers for object storage
|
||||
*
|
||||
* Support different providers for different usage at the same time.
|
||||
*/
|
||||
providers: StorageProvidersConfig;
|
||||
storages: StoragesConfig;
|
||||
providers: {
|
||||
fs?: FsStorageConfig;
|
||||
s3?: S3StorageConfig;
|
||||
r2?: R2StorageConfig;
|
||||
};
|
||||
storages: {
|
||||
avatar: StorageTargetConfig<{ publicLinkFactory: (key: string) => string }>;
|
||||
blob: StorageTargetConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export type StorageProviders = AFFiNEStorageConfig['providers'];
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './payment-required';
|
||||
export * from './too-many-requests';
|
||||
@@ -1,10 +0,0 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class PaymentRequiredException extends HttpException {
|
||||
constructor(desc?: string, code: string = 'Payment Required') {
|
||||
super(
|
||||
HttpException.createBody(desc ?? code, code, HttpStatus.PAYMENT_REQUIRED),
|
||||
HttpStatus.PAYMENT_REQUIRED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export class TooManyRequestsException extends HttpException {
|
||||
constructor(desc?: string, code: string = 'Too Many Requests') {
|
||||
super(
|
||||
HttpException.createBody(
|
||||
desc ?? code,
|
||||
code,
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
),
|
||||
HttpStatus.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,19 +17,17 @@ export type PathType<T, Path extends string> = string extends Path
|
||||
: unknown
|
||||
: unknown;
|
||||
|
||||
export type Leaves<T, P extends string = ''> =
|
||||
T extends Payload<any>
|
||||
? P
|
||||
: T extends Record<string, any>
|
||||
? {
|
||||
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type Flatten<T> =
|
||||
Leaves<T> extends infer R
|
||||
export type Leaves<T, P extends string = ''> = T extends Payload<any>
|
||||
? P
|
||||
: T extends Record<string, any>
|
||||
? {
|
||||
// @ts-expect-error yo, ts can't make it
|
||||
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
|
||||
}
|
||||
[K in keyof T]: K extends string ? Leaves<T[K], Join<P, K>> : never;
|
||||
}[keyof T]
|
||||
: never;
|
||||
|
||||
export type Flatten<T> = Leaves<T> extends infer R
|
||||
? {
|
||||
// @ts-expect-error yo, ts can't make it
|
||||
[K in R]: PathType<T, K> extends Payload<infer U> ? U : never;
|
||||
}
|
||||
: never;
|
||||
|
||||
@@ -3,20 +3,13 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { GQLLoggerPlugin } from './logger-plugin';
|
||||
|
||||
export type GraphqlContext = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
isAdminQuery: boolean;
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -36,48 +29,12 @@ export type GraphqlContext = {
|
||||
: '../../../schema.gql'
|
||||
),
|
||||
sortSchema: true,
|
||||
context: ({
|
||||
req,
|
||||
res,
|
||||
}: {
|
||||
req: Request;
|
||||
res: Response;
|
||||
}): GraphqlContext => ({
|
||||
context: ({ req, res }: { req: Request; res: Response }) => ({
|
||||
req,
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
}),
|
||||
includeStacktraceInErrorResponses: !config.node.prod,
|
||||
plugins: [new GQLLoggerPlugin()],
|
||||
formatError: (formattedError, error) => {
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.extensions ??= {};
|
||||
|
||||
if (
|
||||
error instanceof GraphQLError &&
|
||||
error.originalError instanceof HttpException
|
||||
) {
|
||||
const statusCode = error.originalError.getStatus();
|
||||
const statusName = HttpStatus[statusCode];
|
||||
|
||||
// originally be 'INTERNAL_SERVER_ERROR'
|
||||
formattedError.extensions['code'] = statusCode;
|
||||
formattedError.extensions['status'] = statusName;
|
||||
delete formattedError.extensions['originalError'];
|
||||
|
||||
return formattedError;
|
||||
} else {
|
||||
// @ts-expect-error allow assign
|
||||
formattedError.message = 'Internal Server Error';
|
||||
|
||||
formattedError.extensions['code'] =
|
||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
formattedError.extensions['status'] =
|
||||
HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR];
|
||||
}
|
||||
|
||||
return formattedError;
|
||||
},
|
||||
};
|
||||
},
|
||||
inject: [Config],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user