mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-10 11:28:45 +00:00
Compare commits
79 Commits
v0.11.3-ca
...
v0.12.0-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ceaf87a86 | ||
|
|
d1c4e6141a | ||
|
|
9e7eb5629c | ||
|
|
5dc7cd336f | ||
|
|
2e3ffeced9 | ||
|
|
a84a91d896 | ||
|
|
005c02f148 | ||
|
|
afccf3d8c9 | ||
|
|
296d47f102 | ||
|
|
25e8a2a22f | ||
|
|
5ca0d65241 | ||
|
|
51680da33b | ||
|
|
d9c2dc8dfb | ||
|
|
899528dcfd | ||
|
|
2ed30a0c2e | ||
|
|
514e6f6b80 | ||
|
|
bef266ae3b | ||
|
|
ee3d195811 | ||
|
|
4b3808faf9 | ||
|
|
67ab814108 | ||
|
|
d23f8f8087 | ||
|
|
8f4b4e20ab | ||
|
|
45b5800a23 | ||
|
|
b524564223 | ||
|
|
83e7afeb6b | ||
|
|
2f3c6f104e | ||
|
|
7d951a975f | ||
|
|
5612424b85 | ||
|
|
15d32926c3 | ||
|
|
df8e8051c3 | ||
|
|
338c3001b0 | ||
|
|
fec2090de5 | ||
|
|
61677b2ac4 | ||
|
|
799fa9cfa6 | ||
|
|
aa33bf60d6 | ||
|
|
1db8019292 | ||
|
|
349f7c3f15 | ||
|
|
057796e691 | ||
|
|
e26d978b26 | ||
|
|
e3b8d0dba4 | ||
|
|
f1ccc504b5 | ||
|
|
26db1d436d | ||
|
|
72d9cc1e5b | ||
|
|
db8e49b046 | ||
|
|
5f3c04b51e | ||
|
|
6b350b1735 | ||
|
|
329fc19852 | ||
|
|
1e3499c323 | ||
|
|
2e71c980cf | ||
|
|
4f7e0d012d | ||
|
|
88cd83fed1 | ||
|
|
b3a8e62984 | ||
|
|
48eb6c50e1 | ||
|
|
c9f8e49f75 | ||
|
|
588b3bcf33 | ||
|
|
1cf902bdb6 | ||
|
|
fc8a48fb43 | ||
|
|
0044be972f | ||
|
|
5bd339bed7 | ||
|
|
25e8b8306f | ||
|
|
070d5ca471 | ||
|
|
04b9029d1b | ||
|
|
387e292ed9 | ||
|
|
18068f4ae2 | ||
|
|
b867dcbdeb | ||
|
|
6ca2043697 | ||
|
|
16ef255f51 | ||
|
|
1cf182b7ca | ||
|
|
e8a6b6ad5e | ||
|
|
fd9a7f6aad | ||
|
|
af45b93d26 | ||
|
|
59788aa334 | ||
|
|
fdffe90892 | ||
|
|
db3891ba33 | ||
|
|
e7307d969c | ||
|
|
bd8c7751db | ||
|
|
9aa421d5e1 | ||
|
|
3f96b9778f | ||
|
|
ad1521fd81 |
@@ -64,7 +64,7 @@ const allPackages = [
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/native',
|
||||
'packages/frontend/templates',
|
||||
'packages/frontend/workspace',
|
||||
'packages/frontend/workspace-impl',
|
||||
'packages/common/debug',
|
||||
'packages/common/env',
|
||||
'packages/common/infra',
|
||||
|
||||
1
.github/actions/deploy/deploy.mjs
vendored
1
.github/actions/deploy/deploy.mjs
vendored
@@ -113,6 +113,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`,
|
||||
`--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,
|
||||
|
||||
4
.github/deployment/front/Dockerfile
vendored
4
.github/deployment/front/Dockerfile
vendored
@@ -1,6 +1,6 @@
|
||||
FROM openresty/openresty:1.21.4.3-0-buster
|
||||
FROM openresty/openresty:1.25.3.1-0-buster
|
||||
WORKDIR /app
|
||||
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
|
||||
COPY ./packages/frontend/core/dist ./dist
|
||||
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
||||
|
||||
|
||||
2
.github/deployment/self-host/compose.yaml
vendored
2
.github/deployment/self-host/compose.yaml
vendored
@@ -27,9 +27,7 @@ services:
|
||||
- AFFINE_CONFIG_PATH=/root/.affine/config
|
||||
- REDIS_SERVER_HOST=redis
|
||||
- DATABASE_URL=postgres://affine:affine@postgres:5432/affine
|
||||
- DISABLE_TELEMETRY=true
|
||||
- NODE_ENV=production
|
||||
- SERVER_FLAVOR=selfhosted
|
||||
- AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL}
|
||||
- AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD}
|
||||
redis:
|
||||
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.11.0"
|
||||
appVersion: "0.12.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.11.0"
|
||||
appVersion: "0.12.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -39,6 +39,8 @@ spec:
|
||||
value: "--max-old-space-size=4096"
|
||||
- name: NO_COLOR
|
||||
value: "1"
|
||||
- name: DEPLOYMENT_TYPE
|
||||
value: "affine"
|
||||
- name: SERVER_FLAVOR
|
||||
value: "graphql"
|
||||
- name: AFFINE_ENV
|
||||
@@ -81,6 +83,8 @@ spec:
|
||||
value: "{{ .Values.app.captcha.enabled }}"
|
||||
- name: FEATURES_EARLY_ACCESS_PREVIEW
|
||||
value: "{{ .Values.app.features.earlyAccessPreview }}"
|
||||
- name: FEATURES_SYNC_CLIENT_VERSION_CHECK
|
||||
value: "{{ .Values.app.features.syncClientVersionCheck }}"
|
||||
- name: OAUTH_EMAIL_SENDER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -60,6 +60,7 @@ app:
|
||||
webhookKey: ''
|
||||
features:
|
||||
earlyAccessPreview: false
|
||||
syncClientVersionCheck: false
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.11.0"
|
||||
appVersion: "0.12.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -36,6 +36,8 @@ spec:
|
||||
value: "{{ .Values.env }}"
|
||||
- name: NO_COLOR
|
||||
value: "1"
|
||||
- name: DEPLOYMENT_TYPE
|
||||
value: "affine"
|
||||
- name: SERVER_FLAVOR
|
||||
value: "sync"
|
||||
- name: NEXTAUTH_URL
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -29,11 +29,6 @@ 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:
|
||||
|
||||
12
.github/renovate.json
vendored
12
.github/renovate.json
vendored
@@ -47,17 +47,22 @@
|
||||
"groupName": "electron-forge"
|
||||
},
|
||||
{
|
||||
"groupName": "blocksuite-nightly",
|
||||
"matchPackageNames": ["oxlint"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "oxlint"
|
||||
},
|
||||
{
|
||||
"groupName": "blocksuite-canary",
|
||||
"matchPackagePatterns": ["^@blocksuite"],
|
||||
"excludePackageNames": ["@blocksuite/icons"],
|
||||
"rangeStrategy": "replace",
|
||||
"followTag": "nightly"
|
||||
"followTag": "canary"
|
||||
},
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"excludePackagePatterns": ["^@blocksuite/"],
|
||||
"excludePackagePatterns": ["^@blocksuite/", "oxlint"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
@@ -70,6 +75,7 @@
|
||||
"commitMessageAction": "bump up",
|
||||
"commitMessageTopic": "{{depName}} version",
|
||||
"ignoreDeps": [],
|
||||
"postUpdateOptions": ["yarnDedupeHighest"],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"extends": ["schedule:weekly"]
|
||||
|
||||
45
.github/workflows/build-test.yml
vendored
45
.github/workflows/build-test.yml
vendored
@@ -19,7 +19,7 @@ env:
|
||||
MACOSX_DEPLOYMENT_TARGET: '10.13'
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright
|
||||
DISABLE_TELEMETRY: true
|
||||
DEPLOYMENT_TYPE: affine
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
run: yarn nx test:coverage @affine/monorepo
|
||||
|
||||
- name: Upload unit test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./.coverage/store/lcov.info
|
||||
@@ -209,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-latest, target: x86_64-apple-darwin }
|
||||
- { os: macos-latest, target: aarch64-apple-darwin }
|
||||
- { os: macos-14, target: x86_64-apple-darwin }
|
||||
- { os: macos-14, target: aarch64-apple-darwin }
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -291,6 +291,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-storage
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DISTRIBUTION: browser
|
||||
services:
|
||||
postgres:
|
||||
@@ -353,7 +354,7 @@ jobs:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Upload server test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./packages/backend/server/.coverage/lcov.info
|
||||
@@ -462,22 +463,21 @@ 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-latest,
|
||||
os: macos-14,
|
||||
platform: macos,
|
||||
arch: x64,
|
||||
target: x86_64-apple-darwin,
|
||||
test: true,
|
||||
test: false,
|
||||
}
|
||||
- {
|
||||
os: macos-latest,
|
||||
os: macos-14,
|
||||
platform: macos,
|
||||
arch: arm64,
|
||||
target: aarch64-apple-darwin,
|
||||
test: false,
|
||||
test: true,
|
||||
}
|
||||
- {
|
||||
os: ubuntu-latest,
|
||||
@@ -534,7 +534,7 @@ jobs:
|
||||
run: yarn workspace @affine/electron build
|
||||
|
||||
- name: Run desktop tests
|
||||
if: ${{ matrix.spec.test && matrix.spec.os == 'ubuntu-latest' }}
|
||||
if: ${{ 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
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
run: yarn workspace @affine-test/affine-desktop e2e
|
||||
|
||||
- name: Make bundle
|
||||
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
|
||||
if: ${{ matrix.spec.os == 'macos-14' && matrix.spec.arch == 'arm64' }}
|
||||
env:
|
||||
SKIP_BUNDLE: true
|
||||
SKIP_WEB_BUILD: true
|
||||
@@ -550,7 +550,7 @@ jobs:
|
||||
run: yarn workspace @affine/electron package --platform=darwin --arch=arm64
|
||||
|
||||
- name: Output check
|
||||
if: ${{ matrix.spec.os == 'macos-latest' && matrix.spec.arch == 'arm64' }}
|
||||
if: ${{ matrix.spec.os == 'macos-14' && matrix.spec.arch == 'arm64' }}
|
||||
run: |
|
||||
yarn workspace @affine/electron exec node --loader ts-node/esm/transpile-only ./scripts/macos-arm64-output-check.ts
|
||||
|
||||
@@ -561,3 +561,22 @@ 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')) }}
|
||||
|
||||
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@v1.2.5
|
||||
- uses: kentaro-m/auto-assign-action@v2.0.0
|
||||
|
||||
12
.github/workflows/release-desktop.yml
vendored
12
.github/workflows/release-desktop.yml
vendored
@@ -68,15 +68,13 @@ 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-latest
|
||||
- runner: macos-14
|
||||
platform: darwin
|
||||
arch: x64
|
||||
target: x86_64-apple-darwin
|
||||
- runner: macos-latest
|
||||
- runner: macos-14
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
@@ -153,8 +151,6 @@ 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
|
||||
@@ -228,8 +224,6 @@ 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
|
||||
@@ -281,8 +275,6 @@ jobs:
|
||||
finalize-installer-windows:
|
||||
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
|
||||
|
||||
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.0
|
||||
uses: cloudflare/wrangler-action@v3.4.1
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged && yarn lint:ox
|
||||
|
||||
54
.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch
Normal file
54
.yarn/patches/tinykeys-npm-2.1.0-819feeaed0.patch
Normal file
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.0.2.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.1.0.cjs
|
||||
|
||||
1000
Cargo.lock
generated
1000
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,7 @@
|
||||
|
||||
## 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.
|
||||
Before we tell you how to get started with AFFiNE, we'd like to shamelessly plug our awesome user and developer communities across [official discord server](https://discord.gg/tSNqN4S4)! 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.
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.10.3-canary.2"
|
||||
"version": "0.12.0"
|
||||
}
|
||||
|
||||
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -37,7 +37,8 @@
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc -b tsconfig.json --diagnostics",
|
||||
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install"
|
||||
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown --cache",
|
||||
@@ -60,9 +61,9 @@
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@magic-works/i18n-codegen": "^0.5.0",
|
||||
"@nx/vite": "17.2.8",
|
||||
"@nx/vite": "17.3.1",
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@taplo/cli": "^0.5.2",
|
||||
"@taplo/cli": "^0.7.0",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@types/affine__env": "workspace:*",
|
||||
@@ -70,25 +71,25 @@
|
||||
"@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/vite-plugin": "^4.0.0",
|
||||
"@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.1.4",
|
||||
"@vitest/coverage-istanbul": "1.2.2",
|
||||
"@vitest/ui": "1.2.2",
|
||||
"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": "^10.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.23.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"fake-indexeddb": "5.0.2",
|
||||
"happy-dom": "^13.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"husky": "^9.0.6",
|
||||
"lint-staged": "^15.1.0",
|
||||
"msw": "^2.0.8",
|
||||
"nanoid": "^5.0.3",
|
||||
@@ -105,11 +106,11 @@
|
||||
"vite-plugin-istanbul": "^5.0.0",
|
||||
"vite-plugin-static-copy": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "1.1.3",
|
||||
"vitest": "1.2.2",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"packageManager": "yarn@4.0.2",
|
||||
"packageManager": "yarn@4.1.0",
|
||||
"resolutions": {
|
||||
"vite": "^5.0.6",
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -20,7 +20,7 @@
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.9.5",
|
||||
"@auth/prisma-adapter": "^1.0.7",
|
||||
"@aws-sdk/client-s3": "^3.454.0",
|
||||
"@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",
|
||||
@@ -38,21 +38,21 @@
|
||||
"@nestjs/websockets": "^10.2.10",
|
||||
"@node-rs/argon2": "^1.5.2",
|
||||
"@node-rs/crc32": "^1.7.2",
|
||||
"@node-rs/jsonwebtoken": "^0.3.0",
|
||||
"@node-rs/jsonwebtoken": "^0.4.0",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/core": "^1.20.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.47.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.20.0",
|
||||
"@opentelemetry/host-metrics": "^0.34.0",
|
||||
"@opentelemetry/instrumentation": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.35.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.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.20.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.20.0",
|
||||
"@opentelemetry/sdk-node": "^0.47.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.20.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.20.0",
|
||||
"@prisma/client": "^5.7.1",
|
||||
@@ -162,7 +162,6 @@
|
||||
"env": {
|
||||
"TS_NODE_TRANSPILE_ONLY": true,
|
||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "affine:*",
|
||||
"FORCE_COLOR": true,
|
||||
"DEBUG_COLORS": true
|
||||
|
||||
@@ -265,7 +265,9 @@ model Snapshot {
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
// the `updated_at` field will not record the time of record changed,
|
||||
// but the created time of last seen update that has been merged into snapshot.
|
||||
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@id([id, workspaceId])
|
||||
@@map("snapshots")
|
||||
|
||||
@@ -13,7 +13,10 @@ const configFiles = [
|
||||
];
|
||||
|
||||
function configCleaner(content) {
|
||||
return content.replace(/(\/\/#.*$)|(\/\/\s+TODO.*$)/gm, '');
|
||||
return content.replace(
|
||||
/(^\/\/#.*$)|(^\/\/\s+TODO.*$)|("use\sstrict";?)|(^.*eslint-disable.*$)/gm,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function prepare() {
|
||||
|
||||
@@ -11,6 +11,7 @@ export class AppController {
|
||||
return {
|
||||
compatibility: this.config.version,
|
||||
message: `AFFiNE ${this.config.version} Server`,
|
||||
type: this.config.type,
|
||||
flavor: this.config.flavor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { MailModule } from './fundamentals/mailer';
|
||||
import { MetricsModule } from './fundamentals/metrics';
|
||||
import { PrismaModule } from './fundamentals/prisma';
|
||||
import { SessionModule } from './fundamentals/session';
|
||||
import { StorageProviderModule } from './fundamentals/storage';
|
||||
import { RateLimiterModule } from './fundamentals/throttler';
|
||||
import { WebSocketModule } from './fundamentals/websocket';
|
||||
import { pluginsMap } from './plugins';
|
||||
@@ -43,6 +44,7 @@ export const FunctionalityModules = [
|
||||
RateLimiterModule,
|
||||
SessionModule,
|
||||
MailModule,
|
||||
StorageProviderModule,
|
||||
];
|
||||
|
||||
export class AppModuleBuilder {
|
||||
@@ -109,7 +111,7 @@ export class AppModuleBuilder {
|
||||
},
|
||||
],
|
||||
imports: this.modules,
|
||||
controllers: this.config.flavor.selfhosted ? [] : [AppController],
|
||||
controllers: this.config.isSelfhosted ? [] : [AppController],
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
@@ -132,9 +134,9 @@ function buildAppModule() {
|
||||
// sync server only
|
||||
.useIf(config => config.flavor.sync, SyncModule)
|
||||
|
||||
// main server only
|
||||
// graphql server only
|
||||
.useIf(
|
||||
config => config.flavor.main,
|
||||
config => config.flavor.graphql,
|
||||
ServerConfigModule,
|
||||
WebSocketModule,
|
||||
GqlModule,
|
||||
@@ -147,7 +149,7 @@ function buildAppModule() {
|
||||
|
||||
// self hosted server only
|
||||
.useIf(
|
||||
config => config.flavor.selfhosted,
|
||||
config => config.isSelfhosted,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join('/app', 'static'),
|
||||
})
|
||||
|
||||
@@ -4,9 +4,8 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import { SocketIoAdapter } from './fundamentals';
|
||||
import { SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { ExceptionLogger } from './middleware/exception-logger';
|
||||
import { GlobalExceptionFilter } from './fundamentals';
|
||||
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
|
||||
export async function createApp() {
|
||||
@@ -29,7 +28,7 @@ export async function createApp() {
|
||||
})
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new ExceptionLogger());
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||
app.use(cookieParser());
|
||||
|
||||
if (AFFiNE.flavor.sync) {
|
||||
|
||||
@@ -3,8 +3,7 @@ AFFiNE.ENV_MAP = {
|
||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
||||
AFFINE_SERVER_HOST: 'host',
|
||||
AFFINE_SERVER_SUB_PATH: 'path',
|
||||
AFFIHE_SERVER_HTTPS: ['https', 'boolean'],
|
||||
AFFINE_ENV: 'affineEnv',
|
||||
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
|
||||
DATABASE_URL: 'db.url',
|
||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||
@@ -28,13 +27,15 @@ AFFiNE.ENV_MAP = {
|
||||
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
||||
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
|
||||
DOC_MERGE_USE_JWST_CODEC: [
|
||||
'doc.manager.experimentalMergeWithJwstCodec',
|
||||
'doc.manager.experimentalMergeWithYOcto',
|
||||
'boolean',
|
||||
],
|
||||
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
|
||||
STRIPE_API_KEY: 'plugins.payment.stripe.keys.APIKey',
|
||||
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
|
||||
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
|
||||
FEATURES_SYNC_CLIENT_VERSION_CHECK: [
|
||||
'featureFlags.syncClientVersionCheck',
|
||||
'boolean',
|
||||
],
|
||||
};
|
||||
|
||||
export default AFFiNE;
|
||||
|
||||
46
packages/backend/server/src/config/affine.self.ts
Normal file
46
packages/backend/server/src/config/affine.self.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
// Custom configurations for AFFiNE Cloud
|
||||
// ====================================================================================
|
||||
// Q: WHY THIS FILE EXISTS?
|
||||
// A: AFFiNE deployment environment may have a lot of custom environment variables,
|
||||
// which are not suitable to be put in the `affine.ts` file.
|
||||
// For example, AFFiNE Cloud Clusters are deployed on Google Cloud Platform.
|
||||
// We need to enable the `gcloud` plugin to make sure the nodes working well,
|
||||
// but the default selfhost version may not require it.
|
||||
// So it's not a good idea to put such logic in the common `affine.ts` file.
|
||||
//
|
||||
// ```
|
||||
// if (AFFiNE.deploy) {
|
||||
// AFFiNE.plugins.use('gcloud');
|
||||
// }
|
||||
// ```
|
||||
// ====================================================================================
|
||||
const env = process.env;
|
||||
|
||||
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
||||
|
||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
AFFiNE.plugins.use('cloudflare-r2', {
|
||||
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
||||
credentials: {
|
||||
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
||||
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
AFFiNE.storage.storages.avatar.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
||||
`https://avatar.affineassets.com/${key}`;
|
||||
|
||||
AFFiNE.storage.storages.blob.provider = 'cloudflare-r2';
|
||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||
}`;
|
||||
}
|
||||
|
||||
AFFiNE.plugins.use('redis');
|
||||
AFFiNE.plugins.use('payment');
|
||||
|
||||
if (AFFiNE.deploy) {
|
||||
AFFiNE.plugins.use('gcloud');
|
||||
}
|
||||
@@ -1,39 +1,117 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
// Custom configurations
|
||||
const env = process.env;
|
||||
|
||||
// TODO(@forehalo): detail explained
|
||||
// Storage
|
||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
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 = 'r2';
|
||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
||||
`https://avatar.affineassets.com/${key}`;
|
||||
|
||||
AFFiNE.storage.storages.blob.provider = 'r2';
|
||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||
}`;
|
||||
}
|
||||
|
||||
// Metrics
|
||||
AFFiNE.metrics.enabled = true;
|
||||
|
||||
// Plugins Section Start
|
||||
AFFiNE.plugins.use('payment', {
|
||||
stripe: {
|
||||
keys: {},
|
||||
apiVersion: '2023-10-16',
|
||||
},
|
||||
//
|
||||
// ###############################################################
|
||||
// ## AFFiNE Configuration System ##
|
||||
// ###############################################################
|
||||
// Here is the file of all AFFiNE configurations that will affect runtime behavior.
|
||||
// Override any configuration here and it will be merged when starting the server.
|
||||
// Any changes in this file won't take effect before server restarted.
|
||||
//
|
||||
//
|
||||
// > Configurations merge order
|
||||
// 1. load environment variables (`.env` if provided, and from system)
|
||||
// 2. load `src/fundamentals/config/default.ts` for all default settings
|
||||
// 3. apply `./affine.ts` patches (this file)
|
||||
// 4. apply `./affine.env.ts` patches
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
// ## General settings ##
|
||||
// ###############################################################
|
||||
//
|
||||
// /* The unique identity of the server */
|
||||
// AFFiNE.serverId = 'some-randome-uuid';
|
||||
//
|
||||
// /* The name of AFFiNE Server, may show on the UI */
|
||||
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
|
||||
//
|
||||
// /* Whether the server is deployed behind a HTTPS proxied environment */
|
||||
AFFiNE.https = false;
|
||||
// /* Domain of your server that your server will be available at */
|
||||
AFFiNE.host = 'localhost';
|
||||
// /* The local port of your server that will listen on */
|
||||
AFFiNE.port = 3010;
|
||||
// /* The sub path of your server */
|
||||
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
||||
// AFFiNE.path = '/affine';
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
// ## Database settings ##
|
||||
// ###############################################################
|
||||
//
|
||||
// /* The URL of the database where most of AFFiNE server data will be stored in */
|
||||
// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine';
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
// ## Server Function settings ##
|
||||
// ###############################################################
|
||||
//
|
||||
// /* Whether enable metrics and tracing while running the server */
|
||||
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
|
||||
// AFFiNE.metrics.enabled = true;
|
||||
//
|
||||
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
|
||||
// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */
|
||||
// AFFiNE.graphql = {
|
||||
// /* Path to mount GraphQL API */
|
||||
// path: '/graphql',
|
||||
// buildSchemaOptions: {
|
||||
// numberScalarMode: 'integer',
|
||||
// },
|
||||
// /* Whether allow client to query the schema introspection */
|
||||
// introspection: true,
|
||||
// /* Whether enable GraphQL Playground UI */
|
||||
// playground: true,
|
||||
// }
|
||||
//
|
||||
// /* Doc Store & Collaberation */
|
||||
// /* How long the buffer time of creating a new history snapshot when doc get updated */
|
||||
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
|
||||
//
|
||||
// /* Use `y-octo` to merge updates at the same time when merging using Yjs */
|
||||
// AFFiNE.doc.manager.experimentalMergeWithYOcto = true;
|
||||
//
|
||||
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
|
||||
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
|
||||
//
|
||||
//
|
||||
// ###############################################################
|
||||
// ## Plugins settings ##
|
||||
// ###############################################################
|
||||
//
|
||||
// /* Redis Plugin */
|
||||
// /* Provide caching and session storing backed by Redis. */
|
||||
// /* Useful when you deploy AFFiNE server in a cluster. */
|
||||
AFFiNE.plugins.use('redis', {
|
||||
/* override options */
|
||||
});
|
||||
AFFiNE.plugins.use('redis');
|
||||
// Plugins Section end
|
||||
|
||||
export default AFFiNE;
|
||||
//
|
||||
//
|
||||
// /* 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';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
@@ -69,6 +70,10 @@ class AuthGuard implements CanActivate {
|
||||
'isPublic',
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
// FIXME(@forehalo): @Publicable() is duplicated with @CurrentUser() user?: User
|
||||
// ^ optional
|
||||
// we can prefetch user session in each request even before this `Guard`
|
||||
// api can be public, but if user is logged in, we can get user info
|
||||
const isPublicable = this.reflector.get<boolean>(
|
||||
'isPublicable',
|
||||
@@ -94,7 +99,7 @@ class AuthGuard implements CanActivate {
|
||||
|
||||
const { body = {}, cookies, status = 200 } = session;
|
||||
if (!body && !isPublicable) {
|
||||
return false;
|
||||
throw new UnauthorizedException('You are not signed in.');
|
||||
}
|
||||
|
||||
// @ts-expect-error body is user here
|
||||
|
||||
@@ -244,7 +244,10 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
.count({
|
||||
where: {
|
||||
user: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
|
||||
@@ -107,7 +107,10 @@ export class NextAuthController {
|
||||
if (email) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
|
||||
@@ -136,7 +136,7 @@ export class AuthService {
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(this.config.affineEnv === 'dev' || outcome.hostname === this.config.host)
|
||||
(this.config.node.dev || outcome.hostname === this.config.host)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,10 @@ export class AuthService {
|
||||
async signIn(email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -179,7 +182,10 @@ export class AuthService {
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,7 +219,10 @@ export class AuthService {
|
||||
async createAnonymousUser(email: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -241,9 +250,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -251,7 +263,10 @@ export class AuthService {
|
||||
async isUserHasPassword(email: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
@@ -261,9 +276,12 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../fundamentals';
|
||||
|
||||
export enum ServerFeature {
|
||||
Payment = 'payment',
|
||||
}
|
||||
@@ -9,6 +11,10 @@ registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
const ENABLED_FEATURES: ServerFeature[] = [];
|
||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||
ENABLED_FEATURES.push(feature);
|
||||
@@ -28,6 +34,9 @@ export class ServerConfigType {
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@@ -46,7 +55,11 @@ export class ServerConfigResolver {
|
||||
name: AFFiNE.serverName,
|
||||
version: AFFiNE.version,
|
||||
baseUrl: AFFiNE.baseUrl,
|
||||
flavor: AFFiNE.flavor.type,
|
||||
type: AFFiNE.type,
|
||||
// BACKWARD COMPATIBILITY
|
||||
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||
// this field should be removed after frontend feature flags implemented
|
||||
flavor: AFFiNE.type,
|
||||
features: ENABLED_FEATURES,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
|
||||
import { defer, retry } from 'rxjs';
|
||||
import {
|
||||
applyUpdate,
|
||||
decodeStateVector,
|
||||
Doc,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
|
||||
import {
|
||||
Cache,
|
||||
CallTimer,
|
||||
Config,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
return compare(yBinary, yBinary2, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether rhs state is newer than lhs state.
|
||||
*
|
||||
* How could we tell a state is newer:
|
||||
*
|
||||
* i. if the state vector size is larger, it's newer
|
||||
* ii. if the state vector size is same, compare each client's state
|
||||
*/
|
||||
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
|
||||
const lhsVector = decodeStateVector(lhs);
|
||||
const rhsVector = decodeStateVector(rhs);
|
||||
|
||||
if (lhsVector.size < rhsVector.size) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const [client, state] of lhsVector) {
|
||||
const rstate = rhsVector.get(client);
|
||||
if (!rstate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state < rstate) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isEmptyBuffer(buf: Buffer): boolean {
|
||||
return (
|
||||
buf.length === 0 ||
|
||||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
@CallTimer('doc', 'yjs_recover_updates_to_doc')
|
||||
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
||||
const doc = new Doc();
|
||||
const chunks = chunk(updates, 10);
|
||||
@@ -154,11 +125,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
const doc = await this.recoverDoc(...updates);
|
||||
|
||||
// test jwst codec
|
||||
if (
|
||||
this.config.affine.canary &&
|
||||
this.config.doc.manager.experimentalMergeWithJwstCodec &&
|
||||
updates.length < 100 /* avoid overloading */
|
||||
) {
|
||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||
metrics.jwst.counter('codec_merge_counter').add(1);
|
||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||
let log = false;
|
||||
@@ -209,7 +176,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
}, this.config.doc.manager.updatePollInterval);
|
||||
|
||||
this.logger.log('Automation started');
|
||||
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
|
||||
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||
this.logger.warn(
|
||||
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
||||
);
|
||||
@@ -382,7 +349,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
const updates = await this.getUpdates(workspaceId, guid);
|
||||
|
||||
if (updates.length) {
|
||||
const doc = await this.squash(updates, snapshot);
|
||||
const doc = await this.squash(snapshot, updates);
|
||||
return Buffer.from(encodeStateVector(doc));
|
||||
}
|
||||
|
||||
@@ -415,7 +382,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
// take it ease, we don't want to overload db and or cpu
|
||||
// if we limit the taken number here,
|
||||
// user will never see the latest doc if there are too many updates pending to be merged.
|
||||
take: 100,
|
||||
take: this.config.doc.manager.maxUpdatesPullCount,
|
||||
});
|
||||
|
||||
// perf(memory): avoid sorting in db
|
||||
@@ -463,80 +430,92 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
|
||||
*/
|
||||
@CallTimer('doc', 'upsert')
|
||||
private async upsert(
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
doc: Doc,
|
||||
// we always delay the snapshot update to avoid db overload,
|
||||
// so the value of `updatedAt` will not be accurate to user's real action time
|
||||
// so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
|
||||
updatedAt: Date,
|
||||
initialSeq?: number
|
||||
seq: number
|
||||
) {
|
||||
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
|
||||
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
||||
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
||||
|
||||
if (isEmptyBuffer(blob)) {
|
||||
return false;
|
||||
if (isEmptyBuffer(blob)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const state = Buffer.from(encodeStateVector(doc));
|
||||
|
||||
// CONCERNS:
|
||||
// i. Because we save the real user's last seen action time as `updatedAt`,
|
||||
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
|
||||
//
|
||||
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
|
||||
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
|
||||
// where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
//
|
||||
// iii. Only set the seq number when creating the snapshot.
|
||||
// For updating scenario, the seq number will be updated when updates pushed to db.
|
||||
try {
|
||||
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
|
||||
VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
|
||||
ON CONFLICT ("workspace_id", "guid")
|
||||
DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
|
||||
WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
|
||||
`;
|
||||
|
||||
// const result = await this.db.snapshot.upsert({
|
||||
// select: {
|
||||
// updatedAt: true,
|
||||
// seq: true,
|
||||
// },
|
||||
// where: {
|
||||
// id_workspaceId: {
|
||||
// workspaceId,
|
||||
// id: guid,
|
||||
// },
|
||||
// ⬇️ NOT SUPPORTED BY PRISMA YET
|
||||
// updatedAt: {
|
||||
// lt: updatedAt,
|
||||
// },
|
||||
// },
|
||||
// update: {
|
||||
// blob,
|
||||
// state,
|
||||
// updatedAt,
|
||||
// },
|
||||
// create: {
|
||||
// workspaceId,
|
||||
// id: guid,
|
||||
// blob,
|
||||
// state,
|
||||
// updatedAt,
|
||||
// seq,
|
||||
// },
|
||||
// });
|
||||
|
||||
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
|
||||
// the updates has been applied to current `doc` must have been seen by the other process as well.
|
||||
// The `updatedSnapshot` will be `undefined` in this case.
|
||||
const updatedSnapshot = result.at(0);
|
||||
|
||||
if (!updatedSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const state = Buffer.from(encodeStateVector(doc));
|
||||
|
||||
return await this.db.$transaction(async db => {
|
||||
const snapshot = await db.snapshot.findUnique({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: guid,
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// update
|
||||
if (snapshot) {
|
||||
// only update if state is newer
|
||||
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
|
||||
await db.snapshot.update({
|
||||
select: {
|
||||
seq: true,
|
||||
},
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blob,
|
||||
state,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// create
|
||||
await db.snapshot.create({
|
||||
select: {
|
||||
seq: true,
|
||||
},
|
||||
data: {
|
||||
id: guid,
|
||||
workspaceId,
|
||||
blob,
|
||||
state,
|
||||
seq: initialSeq,
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to upsert snapshot', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _get(
|
||||
@@ -548,7 +527,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
if (updates.length) {
|
||||
return {
|
||||
doc: await this.squash(updates, snapshot),
|
||||
doc: await this.squash(snapshot, updates),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -559,17 +538,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
* Squash updates into a single update and save it as snapshot,
|
||||
* and delete the updates records at the same time.
|
||||
*/
|
||||
private async squash(updates: Update[], snapshot: Snapshot | null) {
|
||||
@CallTimer('doc', 'squash')
|
||||
private async squash(snapshot: Snapshot | null, updates: Update[]) {
|
||||
if (!updates.length) {
|
||||
throw new Error('No updates to squash');
|
||||
}
|
||||
const first = updates[0];
|
||||
const last = updates[updates.length - 1];
|
||||
|
||||
const { id, workspaceId } = first;
|
||||
const last = updates[updates.length - 1];
|
||||
const { id, workspaceId } = last;
|
||||
|
||||
const doc = await this.applyUpdates(
|
||||
first.id,
|
||||
id,
|
||||
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
||||
...updates.map(u => u.blob)
|
||||
);
|
||||
@@ -600,19 +579,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// always delete updates
|
||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||
const { count } = await this.db.update.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
seq: {
|
||||
in: updates.map(u => u.seq),
|
||||
// we will keep the updates only if the upsert failed on unknown reason
|
||||
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
|
||||
// `done === true` means the upsert is successful, safe to be deleted
|
||||
if (done !== false) {
|
||||
// always delete updates
|
||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||
const { count } = await this.db.update.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
seq: {
|
||||
in: updates.map(u => u.seq),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -761,18 +745,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async lockSnapshotForUpsert<T>(
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
job: () => Promise<T>
|
||||
) {
|
||||
return this.doWithLock(
|
||||
'doc:manager:snapshot',
|
||||
`${workspaceId}::${guid}`,
|
||||
job
|
||||
);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async reportUpdatesQueueCount() {
|
||||
metrics.doc
|
||||
|
||||
@@ -50,7 +50,7 @@ export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.UnlimitedWorkspace) {
|
||||
throw new Error('Invalid feature config: type is not EarlyAccess');
|
||||
throw new Error('Invalid feature config: type is not UnlimitedWorkspace');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,10 @@ export class FeatureManagementService {
|
||||
async isEarlyAccessUser(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../features';
|
||||
import { StorageModule } from '../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
import { QuotaService } from './service';
|
||||
@@ -12,8 +13,7 @@ import { QuotaManagementService } from './storage';
|
||||
* - quota statistics
|
||||
*/
|
||||
@Module({
|
||||
// FIXME: Quota really need to know `Storage`?
|
||||
imports: [StorageModule],
|
||||
imports: [FeatureModule, StorageModule],
|
||||
providers: [PermissionService, QuotaService, QuotaManagementService],
|
||||
exports: [QuotaService, QuotaManagementService],
|
||||
})
|
||||
|
||||
@@ -57,6 +57,12 @@ export class QuotaConfig {
|
||||
return this.config.configs.blobLimit;
|
||||
}
|
||||
|
||||
get businessBlobLimit() {
|
||||
return (
|
||||
this.config.configs.businessBlobLimit || this.config.configs.blobLimit
|
||||
);
|
||||
}
|
||||
|
||||
get storageQuota() {
|
||||
return this.config.configs.storageQuota;
|
||||
}
|
||||
|
||||
@@ -71,11 +71,33 @@ export const Quotas: Quota[] = [
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 3,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 10 * OneMB,
|
||||
// server limit will larger then client to handle a edge case:
|
||||
// when a user downgrades from pro to free, he can still continue
|
||||
// to upload previously added files that exceed the free limit
|
||||
// NOTE: this is a product decision, may change in future
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const Quota_FreePlanV1_1 = {
|
||||
feature: Quotas[3].feature,
|
||||
version: Quotas[3].version,
|
||||
feature: Quotas[4].feature,
|
||||
version: Quotas[4].version,
|
||||
};
|
||||
|
||||
export const Quota_ProPlanV1 = {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { FeatureService, FeatureType } from '../features';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
import { OneGB } from './constant';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaQueryType } from './types';
|
||||
import { formatSize, QuotaQueryType } from './types';
|
||||
|
||||
type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
|
||||
|
||||
@Injectable()
|
||||
export class QuotaManagementService {
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
@@ -22,10 +27,10 @@ export class QuotaManagementService {
|
||||
createAt: quota.createdAt,
|
||||
expiredAt: quota.expiredAt,
|
||||
blobLimit: quota.feature.blobLimit,
|
||||
businessBlobLimit: quota.feature.businessBlobLimit,
|
||||
storageQuota: quota.feature.storageQuota,
|
||||
historyPeriod: quota.feature.historyPeriod,
|
||||
memberLimit: quota.feature.memberLimit,
|
||||
humanReadableName: quota.feature.humanReadable.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,16 +47,60 @@ export class QuotaManagementService {
|
||||
|
||||
// get workspace's owner quota and total size of used
|
||||
// quota was apply to owner's account
|
||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaQueryType> {
|
||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||
const { user: owner } =
|
||||
await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
if (!owner) throw new NotFoundException('Workspace owner not found');
|
||||
const { humanReadableName, storageQuota, blobLimit } =
|
||||
await this.getUserQuota(owner.id);
|
||||
const {
|
||||
feature: {
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
humanReadable,
|
||||
},
|
||||
} = await this.quota.getUserQuota(owner.id);
|
||||
// get all workspaces size of owner used
|
||||
const usedSize = await this.getUserUsage(owner.id);
|
||||
|
||||
return { humanReadableName, storageQuota, usedSize, blobLimit };
|
||||
const quota = {
|
||||
name,
|
||||
blobLimit,
|
||||
businessBlobLimit,
|
||||
historyPeriod,
|
||||
memberLimit,
|
||||
storageQuota,
|
||||
humanReadable,
|
||||
usedSize,
|
||||
};
|
||||
|
||||
// relax restrictions if workspace has unlimited feature
|
||||
// todo(@darkskygit): need a mechanism to allow feature as a middleware to edit quota
|
||||
const unlimited = await this.feature.hasWorkspaceFeature(
|
||||
workspaceId,
|
||||
FeatureType.UnlimitedWorkspace
|
||||
);
|
||||
if (unlimited) {
|
||||
return this.mergeUnlimitedQuota(quota);
|
||||
}
|
||||
|
||||
return quota;
|
||||
}
|
||||
|
||||
private mergeUnlimitedQuota(orig: QuotaBusinessType) {
|
||||
return {
|
||||
...orig,
|
||||
storageQuota: 1000 * OneGB,
|
||||
memberLimit: 1000,
|
||||
humanReadable: {
|
||||
...orig.humanReadable,
|
||||
name: 'Unlimited',
|
||||
storageQuota: formatSize(1000 * OneGB),
|
||||
memberLimit: '1000',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async checkBlobQuota(workspaceId: string, size: number) {
|
||||
|
||||
@@ -7,6 +7,13 @@ import { ByteUnit, OneDay, OneKB } from './constant';
|
||||
|
||||
/// ======== quota define ========
|
||||
|
||||
/**
|
||||
* naming rule:
|
||||
* we append Vx to the end of the feature name to indicate the version of the feature
|
||||
* x is a number, start from 1, this number will be change only at the time we change the schema of config
|
||||
* for example, we change the value of `blobLimit` from 10MB to 100MB, then we will only change `version` field from 1 to 2
|
||||
* but if we remove the `blobLimit` field or rename it, then we will change the Vx to Vx+1
|
||||
*/
|
||||
export enum QuotaType {
|
||||
FreePlanV1 = 'free_plan_v1',
|
||||
ProPlanV1 = 'pro_plan_v1',
|
||||
@@ -26,6 +33,7 @@ const quotaPlan = z.object({
|
||||
storageQuota: z.number().positive().int(),
|
||||
historyPeriod: z.number().positive().int(),
|
||||
memberLimit: z.number().positive().int(),
|
||||
businessBlobLimit: z.number().positive().int().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -41,19 +49,46 @@ export type Quota = z.infer<typeof QuotaSchema>;
|
||||
|
||||
/// ======== query types ========
|
||||
|
||||
@ObjectType()
|
||||
export class HumanReadableQuotaType {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => String)
|
||||
blobLimit!: string;
|
||||
|
||||
@Field(() => String)
|
||||
storageQuota!: string;
|
||||
|
||||
@Field(() => String)
|
||||
historyPeriod!: string;
|
||||
|
||||
@Field(() => String)
|
||||
memberLimit!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class QuotaQueryType {
|
||||
@Field(() => String)
|
||||
humanReadableName!: string;
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
memberLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
usedSize!: number;
|
||||
@Field(() => HumanReadableQuotaType)
|
||||
humanReadable!: HumanReadableQuotaType;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobLimit!: number;
|
||||
usedSize!: number;
|
||||
}
|
||||
|
||||
/// ======== utils ========
|
||||
|
||||
@@ -6,15 +6,18 @@ import type {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import { Config, createStorageProvider, OnEvent } from '../../../fundamentals';
|
||||
import { Config, OnEvent, StorageProviderFactory } from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class AvatarStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
private readonly storageConfig: Config['storage']['storages']['avatar'];
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'avatar');
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
) {
|
||||
this.provider = this.storageFactory.create('avatar');
|
||||
this.storageConfig = this.config.storage.storages.avatar;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import type {
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
createStorageProvider,
|
||||
EventEmitter,
|
||||
OnEvent,
|
||||
StorageProviderFactory,
|
||||
} from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,9 +17,9 @@ export class WorkspaceBlobStorage {
|
||||
|
||||
constructor(
|
||||
private readonly event: EventEmitter,
|
||||
private readonly config: Config
|
||||
private readonly storageFactory: StorageProviderFactory
|
||||
) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'blob');
|
||||
this.provider = this.storageFactory.create('blob');
|
||||
}
|
||||
|
||||
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
enum EventErrorCode {
|
||||
export enum EventErrorCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
|
||||
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AccessDeniedError,
|
||||
DocNotFoundError,
|
||||
EventError,
|
||||
EventErrorCode,
|
||||
InternalError,
|
||||
NotInWorkspaceError,
|
||||
} from './error';
|
||||
@@ -112,13 +113,42 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
checkVersion(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}`,
|
||||
});
|
||||
return {
|
||||
error: new EventError(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||
),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake-sync')
|
||||
async handleClientHandshakeSync(
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody() workspaceId: string,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const versionError = this.checkVersion(client, version);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
@@ -143,9 +173,15 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@SubscribeMessage('client-handshake-awareness')
|
||||
async handleClientHandshakeAwareness(
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody() workspaceId: string,
|
||||
@MessageBody('workspaceId') workspaceId: string,
|
||||
@MessageBody('version') version: string | undefined,
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const versionError = this.checkVersion(client, version);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
@@ -172,29 +208,17 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@Auth()
|
||||
@SubscribeMessage('client-handshake')
|
||||
async handleClientHandShake(
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
workspaceId: string,
|
||||
@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),
|
||||
};
|
||||
const versionError = this.checkVersion(client);
|
||||
if (versionError) {
|
||||
return versionError;
|
||||
}
|
||||
// should unreachable
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave-sync')
|
||||
@@ -227,118 +251,6 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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 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 {
|
||||
missing,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-update-v2')
|
||||
async handleClientUpdateV2(
|
||||
@MessageBody()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import {
|
||||
CloudThrottlerGuard,
|
||||
EventEmitter,
|
||||
type FileUpload,
|
||||
PaymentRequiredException,
|
||||
PrismaService,
|
||||
Throttle,
|
||||
} from '../../fundamentals';
|
||||
@@ -97,14 +97,8 @@ export class UserResolver {
|
||||
@Args('email') email?: string
|
||||
) {
|
||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||
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,
|
||||
},
|
||||
}
|
||||
throw new PaymentRequiredException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findUserByEmail(email: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
return this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class WorkspacesController {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
}
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export class WorkspacesController {
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.setHeader('cache-control', 'no-cache');
|
||||
res.send(update);
|
||||
}
|
||||
|
||||
@@ -142,6 +143,7 @@ export class WorkspacesController {
|
||||
|
||||
if (history) {
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
res.send(history.blob);
|
||||
} else {
|
||||
throw new NotFoundException('Doc history not found');
|
||||
|
||||
@@ -119,7 +119,8 @@ export class WorkspaceManagementResolver {
|
||||
async availableFeatures(
|
||||
@CurrentUser() user: UserType
|
||||
): Promise<FeatureType[]> {
|
||||
if (await this.feature.canEarlyAccess(user.email)) {
|
||||
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
|
||||
if (isEarlyAccessUser) {
|
||||
return [FeatureType.Copilot];
|
||||
} else {
|
||||
return [];
|
||||
|
||||
@@ -299,6 +299,18 @@ 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: {
|
||||
@@ -321,26 +333,19 @@ export class PermissionService {
|
||||
}
|
||||
|
||||
async revokePublicPage(ws: string, page: string) {
|
||||
const workspacePage = await this.prisma.workspacePage.findUnique({
|
||||
return this.prisma.workspacePage.upsert({
|
||||
where: {
|
||||
workspaceId_pageId: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!workspacePage) {
|
||||
throw new Error('Page is not public');
|
||||
}
|
||||
|
||||
return this.prisma.workspacePage.update({
|
||||
where: {
|
||||
workspaceId_pageId: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
},
|
||||
update: {
|
||||
public: false,
|
||||
},
|
||||
data: {
|
||||
create: {
|
||||
workspaceId: ws,
|
||||
pageId: page,
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
PayloadTooLargeException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
@@ -8,7 +13,6 @@ import {
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
@@ -128,7 +132,7 @@ export class WorkspaceBlobResolver {
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
const { storageQuota, usedSize, blobLimit } =
|
||||
const { storageQuota, usedSize, businessBlobLimit } =
|
||||
await this.quota.getWorkspaceUsage(workspaceId);
|
||||
|
||||
const unlimited = await this.feature.hasWorkspaceFeature(
|
||||
@@ -138,12 +142,7 @@ export class WorkspaceBlobResolver {
|
||||
|
||||
const checkExceeded = (recvSize: number) => {
|
||||
if (!storageQuota) {
|
||||
throw new GraphQLError('cannot find user quota', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
throw new ForbiddenException('Cannot find user quota.');
|
||||
}
|
||||
const total = usedSize + recvSize;
|
||||
// only skip total storage check if workspace has unlimited feature
|
||||
@@ -152,8 +151,10 @@ export class WorkspaceBlobResolver {
|
||||
`storage size limit exceeded: ${total} > ${storageQuota}`
|
||||
);
|
||||
return true;
|
||||
} else if (recvSize > blobLimit) {
|
||||
this.logger.log(`blob size limit exceeded: ${recvSize} > ${blobLimit}`);
|
||||
} else if (recvSize > businessBlobLimit) {
|
||||
this.logger.log(
|
||||
`blob size limit exceeded: ${recvSize} > ${businessBlobLimit}`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
@@ -161,12 +162,9 @@ export class WorkspaceBlobResolver {
|
||||
};
|
||||
|
||||
if (checkExceeded(0)) {
|
||||
throw new GraphQLError('storage or blob size limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
});
|
||||
throw new PayloadTooLargeException(
|
||||
'Storage or blob size limit exceeded.'
|
||||
);
|
||||
}
|
||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const stream = blob.createReadStream();
|
||||
@@ -178,12 +176,7 @@ export class WorkspaceBlobResolver {
|
||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||
if (checkExceeded(bufferSize)) {
|
||||
reject(
|
||||
new GraphQLError('storage or blob size limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
})
|
||||
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -192,14 +185,7 @@ export class WorkspaceBlobResolver {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
if (checkExceeded(buffer.length)) {
|
||||
reject(
|
||||
new GraphQLError('storage limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
})
|
||||
);
|
||||
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
||||
} else {
|
||||
resolve(buffer);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -111,7 +111,7 @@ export class PagePermissionResolver {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -148,7 +148,7 @@ export class PagePermissionResolver {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
throw new BadRequestException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
@@ -157,6 +157,15 @@ 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,8 +1,9 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
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';
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from '../../../fundamentals';
|
||||
import { Auth, CurrentUser, Public } from '../../auth';
|
||||
import { AuthService } from '../../auth/service';
|
||||
import { FeatureManagementService, FeatureType } from '../../features';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UsersService, UserType } from '../../users';
|
||||
@@ -60,7 +59,6 @@ export class WorkspaceResolver {
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly users: UsersService,
|
||||
private readonly event: EventEmitter,
|
||||
@@ -279,6 +277,7 @@ export class WorkspaceResolver {
|
||||
id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
blob: buffer,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -338,26 +337,15 @@ export class WorkspaceResolver {
|
||||
throw new ForbiddenException('Cannot change owner');
|
||||
}
|
||||
|
||||
const unlimited = await this.feature.hasWorkspaceFeature(
|
||||
workspaceId,
|
||||
FeatureType.UnlimitedWorkspace
|
||||
);
|
||||
if (!unlimited) {
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getUserQuota(user.id),
|
||||
]);
|
||||
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) {
|
||||
throw new PayloadTooLargeException('Workspace member limit reached.');
|
||||
}
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
@@ -409,14 +397,8 @@ export class WorkspaceResolver {
|
||||
`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 new InternalServerErrorException(
|
||||
'Failed to send invite email. Please try again.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export class SelfHostAdmin1605053000403 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient, ref: ModuleRef) {
|
||||
const config = ref.get(Config, { strict: false });
|
||||
if (config.flavor.selfhosted) {
|
||||
if (config.isSelfhosted) {
|
||||
if (
|
||||
!process.env.AFFINE_ADMIN_EMAIL ||
|
||||
!process.env.AFFINE_ADMIN_PASSWORD
|
||||
|
||||
@@ -1,65 +1,14 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { FeatureKind } from '../../core/features';
|
||||
import { Quotas } from '../../core/quota';
|
||||
import { upsertFeature } from './utils/user-features';
|
||||
import { upgradeQuotaVersion } from './utils/user-quotas';
|
||||
|
||||
export class NewFreePlan1705395933447 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
// add new free plan
|
||||
await upsertFeature(db, Quotas[3]);
|
||||
// migrate all free plan users to new free plan
|
||||
await db.$transaction(async tx => {
|
||||
const latestFreePlan = await tx.features.findFirstOrThrow({
|
||||
where: { feature: Quotas[3].feature },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// find all users that have old free plan
|
||||
const userIds = await db.user.findMany({
|
||||
where: {
|
||||
features: {
|
||||
every: {
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
feature: Quotas[3].feature,
|
||||
version: { lt: Quotas[3].version },
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// deactivate all old quota for the user
|
||||
await tx.userFeatures.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId: {
|
||||
in: userIds.map(({ id }) => id),
|
||||
},
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeatures.createMany({
|
||||
data: userIds.map(({ id: userId }) => ({
|
||||
userId,
|
||||
featureId: latestFreePlan.id,
|
||||
reason: 'free plan 1.0 migration',
|
||||
activated: true,
|
||||
})),
|
||||
});
|
||||
});
|
||||
// free plan 1.0
|
||||
const quota = Quotas[3];
|
||||
await upgradeQuotaVersion(db, quota, 'free plan 1.0 migration');
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { Quotas } from '../../core/quota';
|
||||
import { upgradeQuotaVersion } from './utils/user-quotas';
|
||||
|
||||
export class BusinessBlobLimit1706513866287 {
|
||||
// do the migration
|
||||
static async up(db: PrismaClient) {
|
||||
// free plan 1.1
|
||||
const quota = Quotas[4];
|
||||
await upgradeQuotaVersion(db, quota, 'free plan 1.1 migration');
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaClient) {}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { FeatureKind } from '../../../core/features';
|
||||
import { Quota } from '../../../core/quota/types';
|
||||
import { upsertFeature } from './user-features';
|
||||
|
||||
export async function upgradeQuotaVersion(
|
||||
db: PrismaClient,
|
||||
quota: Quota,
|
||||
reason: string
|
||||
) {
|
||||
// add new quota
|
||||
await upsertFeature(db, quota);
|
||||
// migrate all users that using old quota to new quota
|
||||
await db.$transaction(async tx => {
|
||||
const latestQuotaVersion = await tx.features.findFirstOrThrow({
|
||||
where: { feature: quota.feature },
|
||||
orderBy: { version: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// find all users that have old free plan
|
||||
const userIds = await db.user.findMany({
|
||||
where: {
|
||||
features: {
|
||||
every: {
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
feature: quota.feature,
|
||||
version: { lt: quota.version },
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// deactivate all old quota for the user
|
||||
await tx.userFeatures.updateMany({
|
||||
where: {
|
||||
id: undefined,
|
||||
userId: {
|
||||
in: userIds.map(({ id }) => id),
|
||||
},
|
||||
feature: {
|
||||
type: FeatureKind.Quota,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userFeatures.createMany({
|
||||
data: userIds.map(({ id: userId }) => ({
|
||||
userId,
|
||||
featureId: latestQuotaVersion.id,
|
||||
reason,
|
||||
activated: true,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -18,18 +18,22 @@ export enum ExternalAccount {
|
||||
firebase = 'firebase',
|
||||
}
|
||||
|
||||
export type ServerFlavor =
|
||||
| 'allinone'
|
||||
| 'main'
|
||||
// @deprecated
|
||||
| 'graphql'
|
||||
| 'sync'
|
||||
| 'selfhosted';
|
||||
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
|
||||
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
|
||||
export type NODE_ENV = 'development' | 'test' | 'production';
|
||||
|
||||
export enum DeploymentType {
|
||||
Affine = 'affine',
|
||||
Selfhosted = 'selfhosted',
|
||||
}
|
||||
|
||||
export type ConfigPaths = LeafPaths<
|
||||
Omit<
|
||||
AFFiNEConfig,
|
||||
| 'ENV_MAP'
|
||||
| 'version'
|
||||
| 'type'
|
||||
| 'isSelfhosted'
|
||||
| 'flavor'
|
||||
| 'env'
|
||||
| 'affine'
|
||||
@@ -63,27 +67,36 @@ export interface AFFiNEConfig {
|
||||
*/
|
||||
readonly version: string;
|
||||
|
||||
/**
|
||||
* Deployment type, AFFiNE Cloud, or Selfhosted
|
||||
*/
|
||||
get type(): DeploymentType;
|
||||
|
||||
/**
|
||||
* Fast detect whether currently deployed in a selfhosted environment
|
||||
*/
|
||||
get isSelfhosted(): boolean;
|
||||
|
||||
/**
|
||||
* Server flavor
|
||||
*/
|
||||
get flavor(): {
|
||||
type: string;
|
||||
main: boolean;
|
||||
graphql: boolean;
|
||||
sync: boolean;
|
||||
selfhosted: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment environment
|
||||
*/
|
||||
readonly affineEnv: 'dev' | 'beta' | 'production';
|
||||
readonly AFFINE_ENV: AFFINE_ENV;
|
||||
/**
|
||||
* alias to `process.env.NODE_ENV`
|
||||
*
|
||||
* @default 'production'
|
||||
* @default 'development'
|
||||
* @env NODE_ENV
|
||||
*/
|
||||
readonly env: string;
|
||||
readonly NODE_ENV: NODE_ENV;
|
||||
|
||||
/**
|
||||
* fast AFFiNE environment judge
|
||||
@@ -101,6 +114,7 @@ export interface AFFiNEConfig {
|
||||
dev: boolean;
|
||||
test: boolean;
|
||||
};
|
||||
|
||||
get deploy(): boolean;
|
||||
|
||||
/**
|
||||
@@ -159,6 +173,7 @@ export interface AFFiNEConfig {
|
||||
*/
|
||||
featureFlags: {
|
||||
earlyAccessPreview: boolean;
|
||||
syncClientVersionCheck: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -302,11 +317,17 @@ export interface AFFiNEConfig {
|
||||
updatePollInterval: number;
|
||||
|
||||
/**
|
||||
* Use JwstCodec to merge updates at the same time when merging using Yjs.
|
||||
* The maximum number of updates that will be pulled from the server at once.
|
||||
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
|
||||
*/
|
||||
maxUpdatesPullCount: number;
|
||||
|
||||
/**
|
||||
* Use `y-octo` to merge updates at the same time when merging using Yjs.
|
||||
*
|
||||
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
|
||||
*/
|
||||
experimentalMergeWithJwstCodec: boolean;
|
||||
experimentalMergeWithYOcto: boolean;
|
||||
};
|
||||
history: {
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,14 @@ import { merge } from 'lodash-es';
|
||||
import parse from 'parse-duration';
|
||||
|
||||
import pkg from '../../../package.json' assert { type: 'json' };
|
||||
import type { AFFiNEConfig, ServerFlavor } from './def';
|
||||
import {
|
||||
type AFFINE_ENV,
|
||||
AFFiNEConfig,
|
||||
DeploymentType,
|
||||
type NODE_ENV,
|
||||
type ServerFlavor,
|
||||
} from './def';
|
||||
import { readEnv } from './env';
|
||||
import { getDefaultAFFiNEStorageConfig } from './storage';
|
||||
|
||||
// Don't use this in production
|
||||
@@ -46,40 +53,62 @@ const jwtKeyPair = (function () {
|
||||
})();
|
||||
|
||||
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
let isHttps: boolean | null = null;
|
||||
let flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor;
|
||||
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
|
||||
'development',
|
||||
'test',
|
||||
'production',
|
||||
]);
|
||||
const AFFINE_ENV = readEnv<AFFINE_ENV>('AFFINE_ENV', 'dev', [
|
||||
'dev',
|
||||
'beta',
|
||||
'production',
|
||||
]);
|
||||
const flavor = readEnv<ServerFlavor>('SERVER_FLAVOR', 'allinone', [
|
||||
'allinone',
|
||||
'graphql',
|
||||
'sync',
|
||||
]);
|
||||
const deploymentType = readEnv<DeploymentType>(
|
||||
'DEPLOYMENT_TYPE',
|
||||
NODE_ENV === 'development'
|
||||
? DeploymentType.Affine
|
||||
: DeploymentType.Selfhosted,
|
||||
Object.values(DeploymentType)
|
||||
);
|
||||
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
|
||||
|
||||
const defaultConfig = {
|
||||
serverId: 'affine-nestjs-server',
|
||||
serverName: flavor === 'selfhosted' ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
||||
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
||||
version: pkg.version,
|
||||
get type() {
|
||||
return deploymentType;
|
||||
},
|
||||
get isSelfhosted() {
|
||||
return isSelfhosted;
|
||||
},
|
||||
get flavor() {
|
||||
if (flavor === 'graphql') {
|
||||
flavor = 'main';
|
||||
}
|
||||
return {
|
||||
type: flavor,
|
||||
main: flavor === 'main' || flavor === 'allinone',
|
||||
graphql: flavor === 'graphql' || flavor === 'allinone',
|
||||
sync: flavor === 'sync' || flavor === 'allinone',
|
||||
selfhosted: flavor === 'selfhosted',
|
||||
};
|
||||
},
|
||||
ENV_MAP: {},
|
||||
affineEnv: 'dev',
|
||||
AFFINE_ENV,
|
||||
get affine() {
|
||||
const env = this.affineEnv;
|
||||
return {
|
||||
canary: env === 'dev',
|
||||
beta: env === 'beta',
|
||||
stable: env === 'production',
|
||||
canary: AFFINE_ENV === 'dev',
|
||||
beta: AFFINE_ENV === 'beta',
|
||||
stable: AFFINE_ENV === 'production',
|
||||
};
|
||||
},
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
NODE_ENV,
|
||||
get node() {
|
||||
const env = this.env;
|
||||
return {
|
||||
prod: env === 'production',
|
||||
dev: env === 'development',
|
||||
test: env === 'test',
|
||||
prod: NODE_ENV === 'production',
|
||||
dev: NODE_ENV === 'development',
|
||||
test: NODE_ENV === 'test',
|
||||
};
|
||||
},
|
||||
get deploy() {
|
||||
@@ -87,13 +116,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
},
|
||||
featureFlags: {
|
||||
earlyAccessPreview: false,
|
||||
syncClientVersionCheck: false,
|
||||
},
|
||||
get https() {
|
||||
return isHttps ?? !this.node.dev;
|
||||
},
|
||||
set https(value: boolean) {
|
||||
isHttps = value;
|
||||
},
|
||||
https: false,
|
||||
host: 'localhost',
|
||||
port: 3010,
|
||||
path: '',
|
||||
@@ -160,7 +185,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: flavor !== 'sync',
|
||||
updatePollInterval: 3000,
|
||||
experimentalMergeWithJwstCodec: false,
|
||||
maxUpdatesPullCount: 500,
|
||||
experimentalMergeWithYOcto: false,
|
||||
},
|
||||
history: {
|
||||
interval: 1000 * 60 * 10 /* 10 mins */,
|
||||
|
||||
@@ -48,3 +48,24 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readEnv<T>(
|
||||
env: string,
|
||||
defaultValue: T,
|
||||
availableValues?: T[]
|
||||
) {
|
||||
const value = process.env[env];
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (availableValues && !availableValues.includes(value as any)) {
|
||||
throw new Error(
|
||||
`Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join(
|
||||
', '
|
||||
)}]`
|
||||
);
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
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 type StorageTargetConfig<Ext = unknown> = {
|
||||
export interface StorageProvidersConfig {
|
||||
fs: FsStorageConfig;
|
||||
}
|
||||
|
||||
export type StorageProviderType = keyof StorageProvidersConfig;
|
||||
|
||||
export type StorageConfig<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: {
|
||||
fs?: FsStorageConfig;
|
||||
s3?: S3StorageConfig;
|
||||
r2?: R2StorageConfig;
|
||||
};
|
||||
storages: {
|
||||
avatar: StorageTargetConfig<{ publicLinkFactory: (key: string) => string }>;
|
||||
blob: StorageTargetConfig;
|
||||
};
|
||||
providers: StorageProvidersConfig;
|
||||
storages: StoragesConfig;
|
||||
}
|
||||
|
||||
export type StorageProviders = AFFiNEStorageConfig['providers'];
|
||||
|
||||
1
packages/backend/server/src/fundamentals/error/index.ts
Normal file
1
packages/backend/server/src/fundamentals/error/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './payment-required';
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
import { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Global, HttpException, HttpStatus, 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';
|
||||
@@ -34,7 +35,37 @@ import { GQLLoggerPlugin } from './logger-plugin';
|
||||
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],
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GraphQLRequestListener,
|
||||
} from '@apollo/server';
|
||||
import { Plugin } from '@nestjs/apollo';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { HttpException, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { metrics } from '../metrics/metrics';
|
||||
@@ -27,28 +27,44 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
|
||||
metrics.gql.counter('query_counter').add(1, { operation });
|
||||
const start = Date.now();
|
||||
function endTimer() {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
willSendResponse: () => {
|
||||
const costInMilliseconds = Date.now() - start;
|
||||
res.setHeader(
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL"`
|
||||
);
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
const time = endTimer();
|
||||
res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`);
|
||||
metrics.gql.histogram('query_duration').record(time, { operation });
|
||||
return Promise.resolve();
|
||||
},
|
||||
didEncounterErrors: () => {
|
||||
const costInMilliseconds = Date.now() - start;
|
||||
res.setHeader(
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
|
||||
);
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
didEncounterErrors: ctx => {
|
||||
metrics.gql.counter('query_error_counter').add(1, { operation });
|
||||
|
||||
ctx.errors.forEach(err => {
|
||||
// only log non-user errors
|
||||
let msg: string | undefined;
|
||||
|
||||
if (!err.originalError) {
|
||||
msg = err.toString();
|
||||
} else {
|
||||
const originalError = err.originalError;
|
||||
|
||||
// do not log client errors, and put more information in the error extensions.
|
||||
if (!(originalError instanceof HttpException)) {
|
||||
if (originalError.cause && originalError.cause instanceof Error) {
|
||||
msg = originalError.cause.stack ?? originalError.cause.message;
|
||||
} else {
|
||||
msg = originalError.stack ?? originalError.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
this.logger.error('GraphQL Unhandled Error', msg);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,15 +9,22 @@ export {
|
||||
applyEnvToConfig,
|
||||
Config,
|
||||
type ConfigPaths,
|
||||
DeploymentType,
|
||||
getDefaultAFFiNEStorageConfig,
|
||||
} from './config';
|
||||
export * from './error';
|
||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||
export { MailService } from './mailer';
|
||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||
export { getOptionalModuleMetadata, OptionalModule } from './nestjs';
|
||||
export {
|
||||
getOptionalModuleMetadata,
|
||||
GlobalExceptionFilter,
|
||||
OptionalModule,
|
||||
} from './nestjs';
|
||||
export { PrismaService } from './prisma';
|
||||
export { SessionService } from './session';
|
||||
export * from './storage';
|
||||
export { type StorageProvider, StorageProviderFactory } from './storage';
|
||||
export { AuthThrottlerGuard, CloudThrottlerGuard, Throttle } from './throttler';
|
||||
export {
|
||||
getRequestFromHost,
|
||||
@@ -25,4 +32,3 @@ export {
|
||||
getRequestResponseFromHost,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
export { SocketIoAdapter } from './websocket';
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { Global, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
Global,
|
||||
Module,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
Provider,
|
||||
} from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
|
||||
import { Config, parseEnvValue } from '../config';
|
||||
import { createSDK, registerCustomMetrics } from './opentelemetry';
|
||||
import { Config } from '../config';
|
||||
import {
|
||||
LocalOpentelemetryFactory,
|
||||
OpentelemetryFactory,
|
||||
registerCustomMetrics,
|
||||
} from './opentelemetry';
|
||||
|
||||
const factorProvider: Provider = {
|
||||
provide: OpentelemetryFactory,
|
||||
useFactory: (config: Config) => {
|
||||
return config.metrics.enabled ? new LocalOpentelemetryFactory() : null;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({})
|
||||
@Module({
|
||||
providers: [factorProvider],
|
||||
exports: [factorProvider],
|
||||
})
|
||||
export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
||||
private sdk: NodeSDK | null = null;
|
||||
constructor(private readonly config: Config) {}
|
||||
constructor(private readonly ref: ModuleRef) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (
|
||||
this.config.metrics.enabled &&
|
||||
!parseEnvValue(process.env.DISABLE_TELEMETRY, 'boolean')
|
||||
) {
|
||||
this.sdk = createSDK();
|
||||
const factor = this.ref.get(OpentelemetryFactory, { strict: false });
|
||||
if (factor) {
|
||||
this.sdk = factor.create();
|
||||
this.sdk.start();
|
||||
registerCustomMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.config.metrics.enabled && this.sdk) {
|
||||
if (this.sdk) {
|
||||
await this.sdk.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -30,3 +50,4 @@ export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
export * from './metrics';
|
||||
export * from './utils';
|
||||
export { OpentelemetryFactory };
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
||||
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
||||
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
|
||||
import { OnModuleDestroy } from '@nestjs/common';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
CompositePropagator,
|
||||
@@ -18,16 +16,13 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
|
||||
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import {
|
||||
ConsoleMetricExporter,
|
||||
type MeterProvider,
|
||||
MetricProducer,
|
||||
MetricReader,
|
||||
PeriodicExportingMetricReader,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
BatchSpanProcessor,
|
||||
ConsoleSpanExporter,
|
||||
SpanExporter,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from '@opentelemetry/sdk-trace-node';
|
||||
@@ -38,7 +33,7 @@ import { PrismaMetricProducer } from './prisma';
|
||||
|
||||
const { PrismaInstrumentation } = prismaInstrument;
|
||||
|
||||
abstract class OpentelemetryFactor {
|
||||
export abstract class OpentelemetryFactory {
|
||||
abstract getMetricReader(): MetricReader;
|
||||
abstract getSpanExporter(): SpanExporter;
|
||||
|
||||
@@ -59,7 +54,7 @@ abstract class OpentelemetryFactor {
|
||||
|
||||
getResource() {
|
||||
return new Resource({
|
||||
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.affineEnv,
|
||||
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV,
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: AFFiNE.flavor.type,
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: AFFiNE.version,
|
||||
});
|
||||
@@ -85,32 +80,20 @@ abstract class OpentelemetryFactor {
|
||||
}
|
||||
}
|
||||
|
||||
class GCloudOpentelemetryFactor extends OpentelemetryFactor {
|
||||
override getResource(): Resource {
|
||||
return super.getResource().merge(new GcpDetectorSync().detect());
|
||||
export class LocalOpentelemetryFactory
|
||||
extends OpentelemetryFactory
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly metricsExporter = new PrometheusExporter({
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.metricsExporter.shutdown();
|
||||
}
|
||||
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exportIntervalMillis: 30000,
|
||||
exportTimeoutMillis: 10000,
|
||||
exporter: new MetricExporter({
|
||||
prefix: 'custom.googleapis.com',
|
||||
}),
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
return new TraceExporter();
|
||||
}
|
||||
}
|
||||
|
||||
class LocalOpentelemetryFactor extends OpentelemetryFactor {
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PrometheusExporter({
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
return this.metricsExporter;
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
@@ -118,33 +101,6 @@ class LocalOpentelemetryFactor extends OpentelemetryFactor {
|
||||
}
|
||||
}
|
||||
|
||||
class DebugOpentelemetryFactor extends OpentelemetryFactor {
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exporter: new ConsoleMetricExporter(),
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
return new ConsoleSpanExporter();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(@forehalo): make it configurable
|
||||
export function createSDK() {
|
||||
let factor: OpentelemetryFactor | null = null;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
factor = new GCloudOpentelemetryFactor();
|
||||
} else if (process.env.DEBUG_METRICS) {
|
||||
factor = new DebugOpentelemetryFactor();
|
||||
} else {
|
||||
factor = new LocalOpentelemetryFactor();
|
||||
}
|
||||
|
||||
return factor?.create();
|
||||
}
|
||||
|
||||
function getMeterProvider() {
|
||||
return metrics.getMeterProvider();
|
||||
}
|
||||
|
||||
25
packages/backend/server/src/fundamentals/nestjs/exception.ts
Normal file
25
packages/backend/server/src/fundamentals/nestjs/exception.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
|
||||
import { BaseExceptionFilter } from '@nestjs/core';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||
override catch(exception: Error, host: ArgumentsHost) {
|
||||
// with useGlobalFilters, the context is always HTTP
|
||||
|
||||
if (host.getType<GqlContextType>() === 'graphql') {
|
||||
// let Graphql LoggerPlugin handle it
|
||||
// see '../graphql/logger-plugin.ts'
|
||||
throw exception;
|
||||
} else {
|
||||
if (exception instanceof HttpException) {
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
res.status(exception.getStatus()).send(exception.getResponse());
|
||||
return;
|
||||
} else {
|
||||
super.catch(exception, host);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './exception';
|
||||
export * from './optional-module';
|
||||
|
||||
@@ -1,38 +1,24 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
export const StorageProvide = Symbol('Storage');
|
||||
import { registerStorageProvider, StorageProviderFactory } from './providers';
|
||||
import { FsStorageProvider } from './providers/fs';
|
||||
|
||||
let storageModule: typeof import('@affine/storage');
|
||||
try {
|
||||
storageModule = await import('@affine/storage');
|
||||
} catch {
|
||||
const require = createRequire(import.meta.url);
|
||||
storageModule =
|
||||
process.arch === 'arm64'
|
||||
? require('../../../storage.arm64.node')
|
||||
: process.arch === 'arm'
|
||||
? require('../../../storage.armv7.node')
|
||||
: require('../../../storage.node');
|
||||
}
|
||||
registerStorageProvider('fs', (config, bucket) => {
|
||||
if (!config.storage.providers.fs) {
|
||||
throw new Error('Missing fs storage provider configuration');
|
||||
}
|
||||
|
||||
export { storageModule as OctoBaseStorageModule };
|
||||
return new FsStorageProvider(config.storage.providers.fs, bucket);
|
||||
});
|
||||
|
||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||
|
||||
export const verifyChallengeResponse = async (
|
||||
response: any,
|
||||
bits: number,
|
||||
resource: string
|
||||
) => {
|
||||
if (typeof response !== 'string' || !response || !resource) return false;
|
||||
return storageModule.verifyChallengeResponse(response, bits, resource);
|
||||
};
|
||||
|
||||
export const mintChallengeResponse = async (resource: string, bits: number) => {
|
||||
if (!resource) return null;
|
||||
return storageModule.mintChallengeResponse(resource, bits);
|
||||
};
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageProviderFactory],
|
||||
exports: [StorageProviderFactory],
|
||||
})
|
||||
export class StorageProviderModule {}
|
||||
|
||||
export * from './native';
|
||||
export type {
|
||||
BlobInputType,
|
||||
BlobOutputType,
|
||||
@@ -41,5 +27,5 @@ export type {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './providers';
|
||||
export { createStorageProvider } from './providers';
|
||||
export { toBuffer } from './providers/utils';
|
||||
export { registerStorageProvider, StorageProviderFactory } from './providers';
|
||||
export { autoMetadata, toBuffer } from './providers/utils';
|
||||
|
||||
30
packages/backend/server/src/fundamentals/storage/native.ts
Normal file
30
packages/backend/server/src/fundamentals/storage/native.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
let storageModule: typeof import('@affine/storage');
|
||||
try {
|
||||
storageModule = await import('@affine/storage');
|
||||
} catch {
|
||||
const require = createRequire(import.meta.url);
|
||||
storageModule =
|
||||
process.arch === 'arm64'
|
||||
? require('../../../storage.arm64.node')
|
||||
: process.arch === 'arm'
|
||||
? require('../../../storage.armv7.node')
|
||||
: require('../../../storage.node');
|
||||
}
|
||||
|
||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||
|
||||
export const verifyChallengeResponse = async (
|
||||
response: any,
|
||||
bits: number,
|
||||
resource: string
|
||||
) => {
|
||||
if (typeof response !== 'string' || !response || !resource) return false;
|
||||
return storageModule.verifyChallengeResponse(response, bits, resource);
|
||||
};
|
||||
|
||||
export const mintChallengeResponse = async (resource: string, bits: number) => {
|
||||
if (!resource) return null;
|
||||
return storageModule.mintChallengeResponse(resource, bits);
|
||||
};
|
||||
@@ -1,34 +1,37 @@
|
||||
import { AFFiNEStorageConfig, Storages } from '../../config/storage';
|
||||
import { FsStorageProvider } from './fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import type { StorageProviderType, Storages } from '../../config/storage';
|
||||
import type { StorageProvider } from './provider';
|
||||
import { R2StorageProvider } from './r2';
|
||||
import { S3StorageProvider } from './s3';
|
||||
|
||||
export function createStorageProvider(
|
||||
config: AFFiNEStorageConfig,
|
||||
storage: Storages
|
||||
): StorageProvider {
|
||||
const storageConfig = config.storages[storage];
|
||||
const providerConfig = config.providers[storageConfig.provider] as any;
|
||||
if (!providerConfig) {
|
||||
throw new Error(
|
||||
`Failed to create ${storageConfig.provider} storage, configuration not correctly set`
|
||||
);
|
||||
const availableProviders = new Map<
|
||||
StorageProviderType,
|
||||
(config: Config, bucket: string) => StorageProvider
|
||||
>();
|
||||
|
||||
export function registerStorageProvider(
|
||||
type: StorageProviderType,
|
||||
providerFactory: (config: Config, bucket: string) => StorageProvider
|
||||
) {
|
||||
availableProviders.set(type, providerFactory);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageProviderFactory {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
create(storage: Storages): StorageProvider {
|
||||
const storageConfig = this.config.storage.storages[storage];
|
||||
const providerFactory = availableProviders.get(storageConfig.provider);
|
||||
|
||||
if (!providerFactory) {
|
||||
throw new Error(
|
||||
`Unknown storage provider type: ${storageConfig.provider}`
|
||||
);
|
||||
}
|
||||
|
||||
return providerFactory(this.config, storageConfig.bucket);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 's3') {
|
||||
return new S3StorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 'r2') {
|
||||
return new R2StorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 'fs') {
|
||||
return new FsStorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown storage provider type: ${storageConfig.provider}`);
|
||||
}
|
||||
|
||||
export type * from './provider';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/// <reference types="./global.d.ts" />
|
||||
// keep the config import at the top
|
||||
// eslint-disable-next-line simple-import-sort/imports
|
||||
import './prelude';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { createApp } from './app';
|
||||
|
||||
const app = await createApp();
|
||||
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
|
||||
await app.listen(AFFiNE.port, listeningHost);
|
||||
|
||||
console.log(
|
||||
`AFFiNE Server has been started on http://${listeningHost}:${AFFiNE.port}.`
|
||||
);
|
||||
console.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
|
||||
const logger = new Logger('App');
|
||||
|
||||
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
|
||||
logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`);
|
||||
logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
const TrivialExceptions = [NotFoundException];
|
||||
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
@Catch()
|
||||
export class ExceptionLogger implements ExceptionFilter {
|
||||
private readonly logger = new Logger('ExceptionLogger');
|
||||
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
// with useGlobalFilters, the context is always HTTP
|
||||
const ctx = host.switchToHttp();
|
||||
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request?.header(REQUEST_ID_HEADER);
|
||||
|
||||
const shouldVerboseLog = !TrivialExceptions.some(
|
||||
e => exception instanceof e
|
||||
);
|
||||
this.logger.error(
|
||||
new Error(
|
||||
`${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${
|
||||
shouldVerboseLog ? '\n' + exception.stack : ''
|
||||
}`,
|
||||
{ cause: exception }
|
||||
)
|
||||
);
|
||||
|
||||
if (host.getType<GqlContextType>() === 'graphql') {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = ctx.getResponse<Response>();
|
||||
if (exception instanceof HttpException) {
|
||||
response.status(exception.getStatus()).json(exception.getResponse());
|
||||
} else {
|
||||
response.status(500).json({
|
||||
statusCode: 500,
|
||||
error: exception.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,5 @@ export const serverTimingAndCache = (
|
||||
res.setHeader('Server-Timing', serverTimingValue);
|
||||
});
|
||||
|
||||
res.setHeader('Cache-Control', 'max-age=0, private, must-revalidate');
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { GCloudConfig } from './gcloud/config';
|
||||
import { PaymentConfig } from './payment';
|
||||
import { RedisOptions } from './redis';
|
||||
import { R2StorageConfig, S3StorageConfig } from './storage';
|
||||
|
||||
declare module '../fundamentals/config' {
|
||||
interface PluginsConfig {
|
||||
readonly payment: PaymentConfig;
|
||||
readonly redis: RedisOptions;
|
||||
readonly gcloud: GCloudConfig;
|
||||
readonly 'cloudflare-r2': R2StorageConfig;
|
||||
readonly 'aws-s3': S3StorageConfig;
|
||||
}
|
||||
|
||||
export type AvailablePlugins = keyof PluginsConfig;
|
||||
|
||||
1
packages/backend/server/src/plugins/gcloud/config.ts
Normal file
1
packages/backend/server/src/plugins/gcloud/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export interface GCloudConfig {}
|
||||
10
packages/backend/server/src/plugins/gcloud/index.ts
Normal file
10
packages/backend/server/src/plugins/gcloud/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Global } from '@nestjs/common';
|
||||
|
||||
import { OptionalModule } from '../../fundamentals';
|
||||
import { GCloudMetrics } from './metrics';
|
||||
|
||||
@Global()
|
||||
@OptionalModule({
|
||||
imports: [GCloudMetrics],
|
||||
})
|
||||
export class GCloudModule {}
|
||||
46
packages/backend/server/src/plugins/gcloud/metrics.ts
Normal file
46
packages/backend/server/src/plugins/gcloud/metrics.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
||||
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
||||
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
|
||||
import { Global, Provider } from '@nestjs/common';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import {
|
||||
MetricReader,
|
||||
PeriodicExportingMetricReader,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
import { SpanExporter } from '@opentelemetry/sdk-trace-node';
|
||||
|
||||
import { OptionalModule } from '../../fundamentals';
|
||||
import { OpentelemetryFactory } from '../../fundamentals/metrics';
|
||||
|
||||
export class GCloudOpentelemetryFactory extends OpentelemetryFactory {
|
||||
override getResource(): Resource {
|
||||
return super.getResource().merge(new GcpDetectorSync().detect());
|
||||
}
|
||||
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exportIntervalMillis: 30000,
|
||||
exportTimeoutMillis: 10000,
|
||||
exporter: new MetricExporter({
|
||||
prefix: 'custom.googleapis.com',
|
||||
}),
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
return new TraceExporter();
|
||||
}
|
||||
}
|
||||
|
||||
const factorProvider: Provider = {
|
||||
provide: OpentelemetryFactory,
|
||||
useFactory: () => new GCloudOpentelemetryFactory(),
|
||||
};
|
||||
|
||||
@Global()
|
||||
@OptionalModule({
|
||||
if: config => config.metrics.enabled,
|
||||
overrides: [factorProvider],
|
||||
})
|
||||
export class GCloudMetrics {}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { AvailablePlugins } from '../fundamentals/config';
|
||||
import { GCloudModule } from './gcloud';
|
||||
import { PaymentModule } from './payment';
|
||||
import { RedisModule } from './redis';
|
||||
import { AwsS3Module, CloudflareR2Module } from './storage';
|
||||
|
||||
export const pluginsMap = new Map<AvailablePlugins, AFFiNEModule>([
|
||||
['payment', PaymentModule],
|
||||
['redis', RedisModule],
|
||||
['gcloud', GCloudModule],
|
||||
['cloudflare-r2', CloudflareR2Module],
|
||||
['aws-s3', AwsS3Module],
|
||||
]);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { StripeWebhook } from './webhook';
|
||||
// 'plugins.payment.stripe.keys.webhookKey',
|
||||
// ],
|
||||
contributesTo: ServerFeature.Payment,
|
||||
if: config => config.flavor.graphql,
|
||||
})
|
||||
export class PaymentModule {}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
@@ -13,7 +18,6 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { Auth, CurrentUser, Public } from '../../core/auth';
|
||||
@@ -125,6 +129,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class CreateCheckoutSessionInput {
|
||||
@Field(() => SubscriptionRecurring, {
|
||||
nullable: true,
|
||||
defaultValue: SubscriptionRecurring.Yearly,
|
||||
})
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field(() => SubscriptionPlan, {
|
||||
nullable: true,
|
||||
defaultValue: SubscriptionPlan.Pro,
|
||||
})
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
coupon!: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
successCallbackLink!: string | null;
|
||||
|
||||
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
|
||||
@Field(() => String)
|
||||
idempotencyKey!: string;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => UserSubscriptionType)
|
||||
export class SubscriptionResolver {
|
||||
@@ -164,12 +193,9 @@ export class SubscriptionResolver {
|
||||
);
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new GraphQLError('The prices are not configured correctly', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
throw new InternalServerErrorException(
|
||||
'The prices are not configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Mutation(() => String, {
|
||||
deprecationReason: 'use `createCheckoutSession` instead',
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async checkout(
|
||||
@@ -193,18 +223,39 @@ export class SubscriptionResolver {
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring,
|
||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new GraphQLError('Failed to create checkout session', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
throw new BadGatewayException('Failed to create checkout session.');
|
||||
}
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async createCheckoutSession(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
|
||||
input: CreateCheckoutSessionInput
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
plan: input.plan,
|
||||
recurring: input.recurring,
|
||||
promotionCode: input.coupon,
|
||||
redirectUrl:
|
||||
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new BadGatewayException('Failed to create checkout session.');
|
||||
}
|
||||
|
||||
return session.url;
|
||||
@@ -263,20 +314,14 @@ export class UserSubscriptionResolver {
|
||||
) {
|
||||
// allow admin to query other user's subscription
|
||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||
throw new GraphQLError(
|
||||
'You are not allowed to access this subscription',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
'You are not allowed to access this subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
||||
// the frontend should avoid calling such api if feature is not enabled
|
||||
if (this.config.flavor.selfhosted) {
|
||||
if (this.config.isSelfhosted) {
|
||||
const start = new Date();
|
||||
const end = new Date();
|
||||
end.setFullYear(start.getFullYear() + 1);
|
||||
@@ -310,12 +355,9 @@ export class UserSubscriptionResolver {
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||
) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
throw new ForbiddenException(
|
||||
'You are not allowed to access this invoices'
|
||||
);
|
||||
}
|
||||
|
||||
return this.db.userInvoice.findMany({
|
||||
|
||||
@@ -69,13 +69,15 @@ export class SubscriptionService {
|
||||
async createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
plan,
|
||||
promotionCode,
|
||||
redirectUrl,
|
||||
idempotencyKey,
|
||||
plan = SubscriptionPlan.Pro,
|
||||
}: {
|
||||
user: User;
|
||||
plan?: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
plan: SubscriptionPlan;
|
||||
promotionCode?: string | null;
|
||||
redirectUrl: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
@@ -95,7 +97,28 @@ export class SubscriptionService {
|
||||
`${idempotencyKey}-getOrCreateCustomer`,
|
||||
user
|
||||
);
|
||||
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
||||
|
||||
let discount: { coupon?: string; promotion_code?: string } | undefined;
|
||||
|
||||
if (promotionCode) {
|
||||
const code = await this.getAvailablePromotionCode(
|
||||
promotionCode,
|
||||
customer.stripeCustomerId
|
||||
);
|
||||
if (code) {
|
||||
discount ??= {};
|
||||
discount.promotion_code = code;
|
||||
}
|
||||
} else {
|
||||
const coupon = await this.getAvailableCoupon(
|
||||
user,
|
||||
CouponType.EarlyAccess
|
||||
);
|
||||
if (coupon) {
|
||||
discount ??= {};
|
||||
discount.coupon = coupon;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.stripe.checkout.sessions.create(
|
||||
{
|
||||
@@ -108,13 +131,11 @@ export class SubscriptionService {
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
...(coupon
|
||||
...(discount
|
||||
? {
|
||||
discounts: [{ coupon }],
|
||||
discounts: [discount],
|
||||
}
|
||||
: {
|
||||
allow_promotion_codes: true,
|
||||
}),
|
||||
: { allow_promotion_codes: true }),
|
||||
mode: 'subscription',
|
||||
success_url: redirectUrl,
|
||||
customer: customer.stripeCustomerId,
|
||||
@@ -643,4 +664,33 @@ export class SubscriptionService {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getAvailablePromotionCode(
|
||||
userFacingPromotionCode: string,
|
||||
customer?: string
|
||||
) {
|
||||
const list = await this.stripe.promotionCodes.list({
|
||||
code: userFacingPromotionCode,
|
||||
active: true,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const code = list.data[0];
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let available = false;
|
||||
|
||||
if (code.customer) {
|
||||
available =
|
||||
typeof code.customer === 'string'
|
||||
? code.customer === customer
|
||||
: code.customer.id === customer;
|
||||
} else {
|
||||
available = true;
|
||||
}
|
||||
|
||||
return available ? code.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Redis } from 'ioredis';
|
||||
import { Server, ServerOptions } from 'socket.io';
|
||||
|
||||
import { SocketIoAdapter } from '../../fundamentals';
|
||||
import { SocketIoAdapter } from '../../fundamentals/websocket';
|
||||
|
||||
export function createSockerIoAdapterImpl(
|
||||
redis: Redis
|
||||
|
||||
40
packages/backend/server/src/plugins/storage/index.ts
Normal file
40
packages/backend/server/src/plugins/storage/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { OptionalModule } from '../../fundamentals';
|
||||
import { registerStorageProvider } from '../../fundamentals/storage';
|
||||
import { R2StorageProvider } from './providers/r2';
|
||||
import { S3StorageProvider } from './providers/s3';
|
||||
|
||||
registerStorageProvider('cloudflare-r2', (config, bucket) => {
|
||||
if (!config.plugins['cloudflare-r2']) {
|
||||
throw new Error('Missing cloudflare-r2 storage provider configuration');
|
||||
}
|
||||
|
||||
return new R2StorageProvider(config.plugins['cloudflare-r2'], bucket);
|
||||
});
|
||||
registerStorageProvider('aws-s3', (config, bucket) => {
|
||||
if (!config.plugins['aws-s3']) {
|
||||
throw new Error('Missing aws-s3 storage provider configuration');
|
||||
}
|
||||
|
||||
return new S3StorageProvider(config.plugins['aws-s3'], bucket);
|
||||
});
|
||||
|
||||
@OptionalModule({
|
||||
requires: [
|
||||
'plugins.cloudflare-r2.accountId',
|
||||
'plugins.cloudflare-r2.credentials.accessKeyId',
|
||||
'plugins.cloudflare-r2.credentials.secretAccessKey',
|
||||
],
|
||||
if: config => config.flavor.graphql,
|
||||
})
|
||||
export class CloudflareR2Module {}
|
||||
|
||||
@OptionalModule({
|
||||
requires: [
|
||||
'plugins.aws-s3.credentials.accessKeyId',
|
||||
'plugins.aws-s3.credentials.secretAccessKey',
|
||||
],
|
||||
if: config => config.flavor.graphql,
|
||||
})
|
||||
export class AwsS3Module {}
|
||||
|
||||
export type { R2StorageConfig, S3StorageConfig } from './types';
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { R2StorageConfig } from '../../config/storage';
|
||||
import type { R2StorageConfig } from '../types';
|
||||
import { S3StorageProvider } from './s3';
|
||||
|
||||
export class R2StorageProvider extends S3StorageProvider {
|
||||
override readonly type = 'r2' as any /* cast 'r2' to 's3' */;
|
||||
override readonly type = 'cloudflare-r2' as any /* cast 'r2' to 's3' */;
|
||||
|
||||
constructor(config: R2StorageConfig, bucket: string) {
|
||||
super(
|
||||
{
|
||||
...config,
|
||||
forcePathStyle: true,
|
||||
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
||||
},
|
||||
bucket
|
||||
@@ -11,21 +11,22 @@ import {
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { S3StorageConfig } from '../../config/storage';
|
||||
import {
|
||||
autoMetadata,
|
||||
BlobInputType,
|
||||
GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, toBuffer } from './utils';
|
||||
toBuffer,
|
||||
} from '../../../fundamentals/storage';
|
||||
import type { S3StorageConfig } from '../types';
|
||||
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
protected logger: Logger;
|
||||
protected client: S3Client;
|
||||
|
||||
readonly type = 's3';
|
||||
readonly type = 'aws-s3';
|
||||
|
||||
constructor(
|
||||
config: S3StorageConfig,
|
||||
@@ -49,7 +50,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
Body: blob,
|
||||
|
||||
// metadata
|
||||
ContentType: metadata.contentType,
|
||||
16
packages/backend/server/src/plugins/storage/types.ts
Normal file
16
packages/backend/server/src/plugins/storage/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { S3ClientConfigType } from '@aws-sdk/client-s3';
|
||||
|
||||
type WARNING = '__YOU_SHOULD_NOT_MANUALLY_CONFIGURATE_THIS_TYPE__';
|
||||
export type R2StorageConfig = S3ClientConfigType & {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export type S3StorageConfig = S3ClientConfigType;
|
||||
|
||||
declare module '../../fundamentals/config/storage' {
|
||||
interface StorageProvidersConfig {
|
||||
// the type here is only existing for extends [StorageProviderType] with better type inference and checking.
|
||||
'cloudflare-r2'?: WARNING;
|
||||
'aws-s3'?: WARNING;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { omit } from 'lodash-es';
|
||||
|
||||
import {
|
||||
applyEnvToConfig,
|
||||
@@ -43,14 +44,23 @@ async function load() {
|
||||
// 3. load env => config map to `globalThis.AFFiNE.ENV_MAP
|
||||
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.env.js');
|
||||
|
||||
// 4. apply `process.env` map overriding to `globalThis.AFFiNE`
|
||||
applyEnvToConfig(globalThis.AFFiNE);
|
||||
|
||||
// 5. load `config/affine` to patch custom configs
|
||||
// 4. load `config/affine` to patch custom configs
|
||||
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.js');
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('AFFiNE Config:', JSON.stringify(globalThis.AFFiNE, null, 2));
|
||||
// 5. load `config/affine.self` to patch custom configs
|
||||
// This is the file only take effect in [AFFiNE Cloud]
|
||||
if (!AFFiNE.isSelfhosted) {
|
||||
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.self.js');
|
||||
}
|
||||
|
||||
// 6. apply `process.env` map overriding to `globalThis.AFFiNE`
|
||||
applyEnvToConfig(globalThis.AFFiNE);
|
||||
|
||||
if (AFFiNE.node.dev) {
|
||||
console.log(
|
||||
'AFFiNE Config:',
|
||||
JSON.stringify(omit(globalThis.AFFiNE, 'ENV_MAP'), null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
input CreateCheckoutSessionInput {
|
||||
coupon: String
|
||||
idempotencyKey: String!
|
||||
plan: SubscriptionPlan = Pro
|
||||
recurring: SubscriptionRecurring = Yearly
|
||||
successCallbackLink: String
|
||||
}
|
||||
|
||||
"""
|
||||
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||
"""
|
||||
@@ -24,6 +32,14 @@ enum FeatureType {
|
||||
UnlimitedWorkspace
|
||||
}
|
||||
|
||||
type HumanReadableQuotaType {
|
||||
blobLimit: String!
|
||||
historyPeriod: String!
|
||||
memberLimit: String!
|
||||
name: String!
|
||||
storageQuota: String!
|
||||
}
|
||||
|
||||
type InvitationType {
|
||||
"""Invitee information"""
|
||||
invitee: UserType!
|
||||
@@ -99,7 +115,10 @@ type Mutation {
|
||||
changePassword(newPassword: String!, token: String!): UserType!
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String!
|
||||
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
@@ -191,7 +210,10 @@ type Query {
|
||||
|
||||
type QuotaQueryType {
|
||||
blobLimit: SafeInt!
|
||||
humanReadableName: String!
|
||||
historyPeriod: SafeInt!
|
||||
humanReadable: HumanReadableQuotaType!
|
||||
memberLimit: SafeInt!
|
||||
name: String!
|
||||
storageQuota: SafeInt!
|
||||
usedSize: SafeInt!
|
||||
}
|
||||
@@ -218,10 +240,18 @@ type ServerConfigType {
|
||||
"""server identical name could be shown as badge on user interface"""
|
||||
name: String!
|
||||
|
||||
"""server type"""
|
||||
type: ServerDeploymentType!
|
||||
|
||||
"""server version"""
|
||||
version: String!
|
||||
}
|
||||
|
||||
enum ServerDeploymentType {
|
||||
Affine
|
||||
Selfhosted
|
||||
}
|
||||
|
||||
enum ServerFeature {
|
||||
Payment
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ test.afterEach.always(async () => {
|
||||
|
||||
test('should be able to get config', t => {
|
||||
t.true(typeof config.host === 'string');
|
||||
t.is(config.env, 'test');
|
||||
t.is(config.NODE_ENV, 'test');
|
||||
});
|
||||
|
||||
test('should be able to override config', async t => {
|
||||
|
||||
@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
import {
|
||||
applyUpdate,
|
||||
decodeStateVector,
|
||||
Doc as YDoc,
|
||||
encodeStateAsUpdate,
|
||||
} from 'yjs';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { DocManager, DocModule } from '../src/core/doc';
|
||||
import { QuotaModule } from '../src/core/quota';
|
||||
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
|
||||
t.is(stub.callCount, 5);
|
||||
});
|
||||
|
||||
test('should not update snapshot if state is outdated', async t => {
|
||||
const db = m.get(PrismaClient);
|
||||
test('should be able to insert the snapshot if it is new created', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
|
||||
await db.snapshot.create({
|
||||
data: {
|
||||
id: '2',
|
||||
workspaceId: '2',
|
||||
blob: Buffer.from([0, 0]),
|
||||
seq: 1,
|
||||
},
|
||||
});
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
const update = encodeStateAsUpdate(doc);
|
||||
|
||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
||||
await manager.push('1', '1', Buffer.from(update));
|
||||
|
||||
const updateWith3Records = await manager.getUpdates('2', '2');
|
||||
text.insert(11, '!');
|
||||
await manager.push('2', '2', updates[3]);
|
||||
const updateWith4Records = await manager.getUpdates('2', '2');
|
||||
|
||||
// Simulation:
|
||||
// Node A get 3 updates and squash them at time 1, will finish at time 10
|
||||
// Node B get 4 updates and squash them at time 3, will finish at time 8
|
||||
// Node B finish the squash first, and update the snapshot
|
||||
// Node A finish the squash later, and update the snapshot to an outdated state
|
||||
// Time: ---------------------->
|
||||
// A: ^get ^upsert
|
||||
// B: ^get ^upsert
|
||||
//
|
||||
// We should avoid such situation
|
||||
const updates = await manager.getUpdates('1', '1');
|
||||
t.is(updates.length, 1);
|
||||
// @ts-expect-error private
|
||||
await manager.squash(updateWith4Records, null);
|
||||
// @ts-expect-error private
|
||||
await manager.squash(updateWith3Records, null);
|
||||
const snapshot = await manager.squash(null, updates);
|
||||
|
||||
const result = await db.snapshot.findUnique({
|
||||
t.truthy(snapshot);
|
||||
t.is(snapshot.getText('content').toString(), 'hello');
|
||||
|
||||
const restUpdates = await manager.getUpdates('1', '1');
|
||||
|
||||
t.is(restUpdates.length, 0);
|
||||
});
|
||||
|
||||
test('should be able to merge updates into snapshot', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
|
||||
const updates: Buffer[] = [];
|
||||
{
|
||||
const doc = new YDoc();
|
||||
doc.on('update', data => {
|
||||
updates.push(Buffer.from(data));
|
||||
});
|
||||
|
||||
const text = doc.getText('content');
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
text.insert(11, '!');
|
||||
}
|
||||
|
||||
{
|
||||
await manager.batchPush('1', '1', updates.slice(0, 2));
|
||||
// do the merge
|
||||
const doc = (await manager.get('1', '1'))!;
|
||||
|
||||
t.is(doc.getText('content').toString(), 'helloworld');
|
||||
}
|
||||
|
||||
{
|
||||
await manager.batchPush('1', '1', updates.slice(2));
|
||||
const doc = (await manager.get('1', '1'))!;
|
||||
|
||||
t.is(doc.getText('content').toString(), 'hello world!');
|
||||
}
|
||||
|
||||
const restUpdates = await manager.getUpdates('1', '1');
|
||||
|
||||
t.is(restUpdates.length, 0);
|
||||
});
|
||||
|
||||
test('should not update snapshot if doc is outdated', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
const db = m.get(PrismaClient);
|
||||
|
||||
const updates: Buffer[] = [];
|
||||
{
|
||||
const doc = new YDoc();
|
||||
doc.on('update', data => {
|
||||
updates.push(Buffer.from(data));
|
||||
});
|
||||
|
||||
const text = doc.getText('content');
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
text.insert(11, '!');
|
||||
}
|
||||
|
||||
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
|
||||
// merge updates into snapshot
|
||||
await manager.get('2', '1');
|
||||
// fake the snapshot is a lot newer
|
||||
await db.snapshot.update({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: '2',
|
||||
workspaceId: '2',
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(Date.now() + 10000),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
t.fail('snapshot not found');
|
||||
return;
|
||||
{
|
||||
const snapshot = await manager.getSnapshot('2', '1');
|
||||
await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
|
||||
const updateRecords = await manager.getUpdates('2', '1');
|
||||
|
||||
// @ts-expect-error private
|
||||
const doc = await manager.squash(snapshot, updateRecords);
|
||||
|
||||
// all updated will merged into doc not matter it's timestamp is outdated or not,
|
||||
// but the snapshot record will not be updated
|
||||
t.is(doc.getText('content').toString(), 'hello world!');
|
||||
}
|
||||
|
||||
const state = decodeStateVector(result.state!);
|
||||
t.is(state.get(doc.clientID), 12);
|
||||
{
|
||||
const doc = new YDoc();
|
||||
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
|
||||
// the snapshot will not get touched if the new doc's timestamp is outdated
|
||||
t.is(doc.getText('content').toString(), 'helloworld');
|
||||
|
||||
const d = new YDoc();
|
||||
applyUpdate(d, result.blob!);
|
||||
|
||||
const dtext = d.getText('content');
|
||||
t.is(dtext.toString(), 'hello world!');
|
||||
// the updates are known as outdated, so they will be deleted
|
||||
t.is((await manager.getUpdates('2', '1')).length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
90
packages/backend/server/tests/graphql.spec.ts
Normal file
90
packages/backend/server/tests/graphql.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
INestApplication,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import testFn, { TestFn } from 'ava';
|
||||
import request from 'supertest';
|
||||
|
||||
import { ConfigModule } from '../src/fundamentals/config';
|
||||
import { GqlModule } from '../src/fundamentals/graphql';
|
||||
|
||||
@Resolver(() => String)
|
||||
class TestResolver {
|
||||
greating = 'hello world';
|
||||
|
||||
@Query(() => String)
|
||||
hello() {
|
||||
return this.greating;
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
update(@Args('greating') greating: string) {
|
||||
this.greating = greating;
|
||||
return this.greating;
|
||||
}
|
||||
|
||||
@Query(() => String)
|
||||
errorQuery() {
|
||||
throw new ForbiddenException('forbidden query');
|
||||
}
|
||||
|
||||
@Query(() => String)
|
||||
unknownErrorQuery() {
|
||||
throw new Error('unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
const test = testFn as TestFn<{ app: INestApplication }>;
|
||||
|
||||
function gql(app: INestApplication, query: string) {
|
||||
return request(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.send({ query })
|
||||
.expect(200);
|
||||
}
|
||||
|
||||
test.beforeEach(async ctx => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot(), GqlModule],
|
||||
providers: [TestResolver],
|
||||
}).compile();
|
||||
|
||||
ctx.context.app = await module
|
||||
.createNestApplication({
|
||||
logger: false,
|
||||
})
|
||||
.init();
|
||||
});
|
||||
|
||||
test('should be able to execute query', async t => {
|
||||
const res = await gql(t.context.app, `query { hello }`);
|
||||
t.is(res.body.data.hello, 'hello world');
|
||||
});
|
||||
|
||||
test('should be able to execute mutation', async t => {
|
||||
const res = await gql(t.context.app, `mutation { update(greating: "hi") }`);
|
||||
|
||||
t.is(res.body.data.update, 'hi');
|
||||
|
||||
const newRes = await gql(t.context.app, `query { hello }`);
|
||||
t.is(newRes.body.data.hello, 'hi');
|
||||
});
|
||||
|
||||
test('should be able to handle known http exception', async t => {
|
||||
const res = await gql(t.context.app, `query { errorQuery }`);
|
||||
const err = res.body.errors[0];
|
||||
t.is(err.message, 'forbidden query');
|
||||
t.is(err.extensions.code, HttpStatus.FORBIDDEN);
|
||||
t.is(err.extensions.status, HttpStatus[HttpStatus.FORBIDDEN]);
|
||||
});
|
||||
|
||||
test('should be able to handle unknown internal error', async t => {
|
||||
const res = await gql(t.context.app, `query { unknownErrorQuery }`);
|
||||
const err = res.body.errors[0];
|
||||
t.is(err.message, 'Internal Server Error');
|
||||
t.is(err.extensions.code, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
t.is(err.extensions.status, HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR]);
|
||||
});
|
||||
@@ -48,7 +48,7 @@ test('should be able to set quota', async t => {
|
||||
const q1 = await quota.getUserQuota(u1.id);
|
||||
t.truthy(q1, 'should have quota');
|
||||
t.is(q1?.feature.name, QuotaType.FreePlanV1, 'should be free plan');
|
||||
t.is(q1?.feature.version, 2, 'should be version 2');
|
||||
t.is(q1?.feature.version, 3, 'should be version 2');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
|
||||
@@ -64,8 +64,8 @@ test('should be able to check storage quota', async t => {
|
||||
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
||||
|
||||
const q1 = await storageQuota.getUserQuota(u1.id);
|
||||
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan');
|
||||
t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
const q2 = await storageQuota.getUserQuota(u1.id);
|
||||
@@ -78,8 +78,8 @@ test('should be able revert quota', async t => {
|
||||
const u1 = await auth.signUp('DarkSky', 'darksky@example.org', '123456');
|
||||
|
||||
const q1 = await storageQuota.getUserQuota(u1.id);
|
||||
t.is(q1?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, Quotas[3].configs.storageQuota, 'should be free plan');
|
||||
t.is(q1?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
|
||||
t.is(q1?.storageQuota, Quotas[4].configs.storageQuota, 'should be free plan');
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.ProPlanV1);
|
||||
const q2 = await storageQuota.getUserQuota(u1.id);
|
||||
@@ -88,7 +88,7 @@ test('should be able revert quota', async t => {
|
||||
|
||||
await quota.switchUserQuota(u1.id, QuotaType.FreePlanV1);
|
||||
const q3 = await storageQuota.getUserQuota(u1.id);
|
||||
t.is(q3?.blobLimit, Quotas[3].configs.blobLimit, 'should be free plan');
|
||||
t.is(q3?.blobLimit, Quotas[4].configs.blobLimit, 'should be free plan');
|
||||
|
||||
const quotas = await quota.getUserQuotas(u1.id);
|
||||
t.is(quotas.length, 3, 'should have 3 quotas');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user