Merge branch 'canary' into stable

This commit is contained in:
李华桥
2024-01-25 00:33:28 +08:00
439 changed files with 15012 additions and 6822 deletions
+1
View File
@@ -217,6 +217,7 @@ const config = {
'unicorn/no-useless-promise-resolve-reject': 'error',
'unicorn/no-new-array': 'error',
'unicorn/new-for-builtins': 'error',
'unicorn/prefer-node-protocol': 'error',
'sonarjs/no-all-duplicated-branches': 'error',
'sonarjs/no-element-overwrite': 'error',
'sonarjs/no-empty-collection': 'error',
+1 -1
View File
@@ -37,7 +37,7 @@ runs:
echo "TARGET_CC=clang" >> "$GITHUB_ENV"
- name: Cache cargo
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
+10 -2
View File
@@ -65,8 +65,16 @@ const createHelmCommand = ({ isDryRun }) => {
]
: [];
const webReplicaCount = isProduction ? 3 : isBeta ? 2 : 2;
const graphqlReplicaCount = isProduction ? 10 : isBeta ? 5 : 2;
const syncReplicaCount = isProduction ? 10 : isBeta ? 5 : 2;
const graphqlReplicaCount = isProduction
? Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 3
: isBeta
? Number(process.env.isBeta_GRAPHQL_REPLICA) || 2
: 2;
const syncReplicaCount = isProduction
? Number(process.env.PRODUCTION_SYNC_REPLICA) || 3
: isBeta
? Number(process.env.BETA_SYNC_REPLICA) || 2
: 2;
const namespace = isProduction
? 'production'
: isBeta
+6 -6
View File
@@ -63,7 +63,7 @@ runs:
run: node -e "const p = $(yarn config cacheFolder --json).effective; console.log('yarn_global_cache=' + p)" >> $GITHUB_OUTPUT
- name: Cache non-full yarn cache on Linux
uses: actions/cache@v3
uses: actions/cache@v4
if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }}
with:
path: |
@@ -75,7 +75,7 @@ runs:
# and the decompression performance on Windows is very terrible
# so we reduce the number of cached files on non-Linux systems by remove node_modules from cache path.
- name: Cache non-full yarn cache on non-Linux
uses: actions/cache@v3
uses: actions/cache@v4
if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }}
with:
path: |
@@ -83,7 +83,7 @@ runs:
key: node_modules-cache-${{ github.job }}-${{ runner.os }}
- name: Cache full yarn cache on Linux
uses: actions/cache@v3
uses: actions/cache@v4
if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }}
with:
path: |
@@ -92,7 +92,7 @@ runs:
key: node_modules-cache-full-${{ runner.os }}
- name: Cache full yarn cache on non-Linux
uses: actions/cache@v3
uses: actions/cache@v4
if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }}
with:
path: |
@@ -134,7 +134,7 @@ runs:
# Note: Playwright's cache directory is hard coded because that's what it
# says to do in the docs. There doesn't appear to be a command that prints
# it out for us.
- uses: actions/cache@v3
- uses: actions/cache@v4
id: playwright-cache
if: ${{ inputs.playwright-install == 'true' }}
with:
@@ -167,7 +167,7 @@ runs:
run: |
echo "version=$(yarn why --json electron | grep -h 'workspace:.' | jq --raw-output '.children[].locator' | sed -e 's/@playwright\/test@.*://' | head -n 1)" >> $GITHUB_OUTPUT
- uses: actions/cache@v3
- uses: actions/cache@v4
id: electron-cache
if: ${{ inputs.electron-install == 'true' }}
with:
+1 -1
View File
@@ -1,6 +1,6 @@
FROM openresty/openresty:1.21.4.3-0-buster
WORKDIR /app
COPY ./packages/frontend/core/dist ./dist
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
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
+1
View File
@@ -1,6 +1,7 @@
FROM node:18-bookworm-slim
COPY ./packages/backend/server /app
COPY ./packages/frontend/core/dist /app/static
WORKDIR /app
RUN apt-get update && \
+61
View File
@@ -0,0 +1,61 @@
services:
affine:
image: ghcr.io/toeverything/affine-graphql:beta
container_name: affine_selfhosted
command:
['sh', '-c', 'node ./scripts/self-host-predeploy && node ./dist/index.js']
ports:
- '3010:3010'
- '5555:5555'
depends_on:
redis:
condition: service_healthy
postgres:
condition: service_healthy
volumes:
# custom configurations
- ~/.affine/self-host/config:/root/.affine/config
# blob storage
- ~/.affine/self-host/storage:/root/.affine/storage
logging:
driver: 'json-file'
options:
max-size: '1000m'
restart: unless-stopped
environment:
- NODE_OPTIONS=--es-module-specifier-resolution node
- 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:
image: redis
container_name: affine_redis
restart: unless-stopped
volumes:
- ~/.affine/self-host/redis:/data
healthcheck:
test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']
interval: 10s
timeout: 5s
retries: 5
postgres:
image: postgres
container_name: affine_postgres
restart: unless-stopped
volumes:
- ~/.affine/self-host/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U affine']
interval: 10s
timeout: 5s
retries: 5
environment:
POSTGRES_USER: affine
POSTGRES_PASSWORD: affine
POSTGRES_DB: affine
PGDATA: /var/lib/postgresql/data/pgdata
@@ -73,6 +73,8 @@ spec:
value: "{{ .Values.app.path }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
- name: AFFINE_SERVER_HTTPS
value: "{{ .Values.app.https }}"
- name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.app.objectStorage.r2.enabled }}"
- name: ENABLE_CAPTCHA
@@ -145,6 +147,8 @@ spec:
key: turnstileSecret
{{ end }}
{{ if .Values.app.oauth.google.enabled }}
- name: OAUTH_GOOGLE_ENABLED
value: "true"
- name: OAUTH_GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
@@ -16,6 +16,7 @@ app:
path: ''
# AFFINE_SERVER_HOST
host: '0.0.0.0'
https: true
doc:
mergeInterval: "3000"
jwt:
+3
View File
@@ -19,6 +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
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -95,6 +96,8 @@ jobs:
run: |
git checkout .yarnrc.yml
yarn lint:prettier
- name: Yarn Dedupe
run: yarn dedupe --check
- name: Run Type Check
run: yarn typecheck
+55 -22
View File
@@ -29,6 +29,7 @@ jobs:
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/server
- name: Build Server
run: yarn workspace @affine/server build
- name: Upload server dist
@@ -62,6 +63,7 @@ jobs:
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
@@ -69,10 +71,10 @@ jobs:
path: ./packages/frontend/core/dist
if-no-files-found: error
build-storage:
name: Build Storage
build-core-selfhost:
name: Build @affine/core
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
@@ -80,22 +82,33 @@ jobs:
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-unknown-linux-gnu'
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
- name: Build Core
run: yarn nx build @affine/core --skip-nx-cache
env:
BUILD_TYPE: ${{ github.event.inputs.flavor }}
SHOULD_REPORT_TRACE: false
PUBLIC_PATH: '/'
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload core artifact
uses: actions/upload-artifact@v4
with:
name: storage.node
path: ./packages/backend/storage/storage.node
name: selfhost-core
path: ./packages/frontend/core/dist
if-no-files-found: error
build-storage-arm64:
name: Build Storage arm64
build-storage:
name: Build Storage - ${{ matrix.targets.name }}
runs-on: ubuntu-latest
strategy:
matrix:
targets:
- name: x86_64-unknown-linux-gnu
file: storage.node
- name: aarch64-unknown-linux-gnu
file: storage.arm64.node
- name: armv7-unknown-linux-gnueabihf
file: storage.armv7.node
steps:
- uses: actions/checkout@v4
@@ -104,16 +117,19 @@ jobs:
uses: ./.github/actions/setup-version
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/storage
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: 'aarch64-unknown-linux-gnu'
target: ${{ matrix.targets.name }}
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
- name: Upload ${{ matrix.targets.file }}
uses: actions/upload-artifact@v4
with:
name: storage.arm64.node
name: ${{ matrix.targets.file }}
path: ./packages/backend/storage/storage.node
if-no-files-found: error
@@ -123,8 +139,8 @@ jobs:
needs:
- build-server
- build-core
- build-core-selfhost
- build-storage
- build-storage-arm64
steps:
- uses: actions/checkout@v4
- name: Download core artifact
@@ -147,8 +163,15 @@ jobs:
with:
name: storage.arm64.node
path: ./packages/backend/storage
- name: move storage.arm64.node
run: mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
- name: Download storage.node arm64
uses: actions/download-artifact@v4
with:
name: storage.armv7.node
path: .
- name: move storage files
run: |
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
mv storage.node ./packages/backend/server/storage.armv7.node
- name: Setup env
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
@@ -190,9 +213,19 @@ jobs:
registry-url: https://npm.pkg.github.com
scope: '@toeverything'
- name: Remove core dist
run: rm -rf ./packages/frontend/core/dist
- name: Download selfhost core artifact
uses: actions/download-artifact@v4
with:
name: selfhost-core
path: ./packages/frontend/core/dist
- name: Install Node.js dependencies
run: |
yarn config set --json supportedArchitectures.cpu '["x64", "arm64"]'
yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
yarn config set --json supportedArchitectures.libc '["glibc"]'
yarn workspaces focus @affine/server --production
- name: Generate Prisma client
@@ -204,7 +237,7 @@ jobs:
context: .
push: true
pull: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
file: .github/deployment/node/Dockerfile
tags: ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:${{env.RELEASE_FLAVOR}}
@@ -0,0 +1,51 @@
name: Publish UI Storybook
env:
NODE_OPTIONS: --max-old-space-size=4096
on:
workflow_dispatch:
push:
branches:
- canary
pull_request:
branches:
- canary
paths-ignore:
- README.md
- .github/**
- packages/backend/server
- packages/frontend/electron
- '!.github/workflows/publish-storybook.yml'
jobs:
publish-ui-storybook:
name: Publish UI Storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
# This is required to fetch all commits for chromatic
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- uses: chromaui/action-next@v1
with:
workingDir: packages/frontend/component
buildScriptName: build:storybook
exitOnceUploaded: true
onlyChanged: false
diagnostics: true
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_UI_PROJECT_TOKEN }}
NODE_OPTIONS: ${{ env.NODE_OPTIONS }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: chromatic-build-artifacts-${{ github.run_id }}
path: |
chromatic-diagnostics.json
**/build-storybook.log
-18
View File
@@ -138,24 +138,6 @@ We would like to express our gratitude to all the individuals who have already c
<img alt="contributors" src="https://opencollective.com/affine/contributors.svg?width=890&button=false" />
</a>
## Data Compatibility
Data compatibility is a very important issue for us. We will try our best to ensure that the data is compatible with the previous version.
If you encounter any problems when upgrading the version, please feel free to [contact us](mailto:developer@toeverything.info).
| AFFiNE Version | Export/Import workspace | Data auto migration |
| --------------- | ----------------------- | ------------------- |
| <= 0.5.4 | ❌️ | ❌ |
| 0.6.x | ✅️ | ✅ |
| 0.7.x | ✅️ | ✅ |
| 0.8.x (current) | ✅ | ✅ |
| 0.9.x (next) | 🚧 | 🚧 |
- ❌️: Not compatible
- ✅: Compatible
- 🚧: Work in progress
## Self-Host
> We know that the self-host version has been out of date for a long time.
+2 -1
View File
@@ -1,12 +1,13 @@
{
"$schema": "./node_modules/nx/schemas/nx-schema.json",
"npmScope": "toeverything",
"nxCloudAccessToken": "MzUwNTU4YWItZGFhYi00YjE2LWIxODAtODk4NmIwYjMwYzZkfHJlYWQ=",
"tasksRunnerOptions": {
"default": {
"runner": "nx-cloud",
"options": {
"cacheableOperations": ["build", "test", "e2e", "lint"],
"accessToken": "YmQ2NTg1ODktZTk5Mi00YzhiLTk2ZmUtNWQzMDg0NDBkOWM3fHJlYWQtb25seQ=="
"runtimeCacheInputs": ["node -v"]
}
}
},
+4 -6
View File
@@ -61,8 +61,7 @@
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.5.0",
"@nx/vite": "17.2.8",
"@perfsee/sdk": "^1.9.0",
"@playwright/test": "^1.40.0",
"@playwright/test": "^1.41.0",
"@taplo/cli": "^0.5.2",
"@testing-library/react": "^14.1.2",
"@toeverything/infra": "workspace:*",
@@ -76,7 +75,7 @@
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-istanbul": "1.1.3",
"@vitest/ui": "1.1.3",
"electron": "^27.1.0",
"electron": "^28.1.4",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-i": "^2.29.0",
@@ -88,13 +87,12 @@
"eslint-plugin-unused-imports": "^3.0.0",
"eslint-plugin-vue": "^9.18.1",
"fake-indexeddb": "5.0.2",
"happy-dom": "^12.10.3",
"happy-dom": "^13.0.0",
"husky": "^8.0.3",
"lint-staged": "^15.1.0",
"msw": "^2.0.8",
"nanoid": "^5.0.3",
"nx": "^17.1.3",
"nx-cloud": "^16.5.2",
"nx": "^17.2.8",
"nyc": "^15.1.0",
"oxlint": "0.0.22",
"prettier": "^3.1.0",
+4 -8
View File
@@ -1,8 +1,4 @@
DATABASE_URL="postgresql://affine@localhost:5432/affine"
NEXTAUTH_URL="http://localhost:8080"
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
OAUTH_EMAIL_LOGIN=""
OAUTH_EMAIL_PASSWORD=""
ENABLE_LOCAL_EMAIL="true"
STRIPE_API_KEY=
STRIPE_WEBHOOK_KEY=
# AFFINE_SERVER_PORT=3010
# AFFINE_SERVER_HOST=app.affine.pro
# AFFINE_SERVER_HTTPS=true
# DATABASE_URL="postgres://affine@localhost:5432/affine"
+17 -11
View File
@@ -14,8 +14,8 @@
"test": "ava --concurrency 1 --serial",
"test:coverage": "c8 ava --concurrency 1 --serial",
"postinstall": "prisma generate",
"data-migration": "node --loader ts-node/esm/transpile-only.mjs --es-module-specifier-resolution node ./src/data/app.ts",
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/app.js run"
"data-migration": "node --loader ts-node/esm/transpile-only.mjs --es-module-specifier-resolution node ./src/data/index.ts",
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/index.js run"
},
"dependencies": {
"@apollo/server": "^4.9.5",
@@ -23,6 +23,7 @@
"@aws-sdk/client-s3": "^3.454.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@google-cloud/opentelemetry-resource-util": "^2.1.0",
"@keyv/redis": "^2.8.0",
"@nestjs/apollo": "^12.0.11",
"@nestjs/common": "^10.2.10",
@@ -32,35 +33,39 @@
"@nestjs/platform-express": "^10.2.10",
"@nestjs/platform-socket.io": "^10.2.10",
"@nestjs/schedule": "^4.0.0",
"@nestjs/serve-static": "^4.0.0",
"@nestjs/throttler": "^5.0.1",
"@nestjs/websockets": "^10.2.10",
"@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.3.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.19.0",
"@opentelemetry/exporter-prometheus": "^0.46.0",
"@opentelemetry/exporter-zipkin": "^1.19.0",
"@opentelemetry/core": "^1.20.0",
"@opentelemetry/exporter-prometheus": "^0.47.0",
"@opentelemetry/exporter-zipkin": "^1.20.0",
"@opentelemetry/host-metrics": "^0.34.0",
"@opentelemetry/instrumentation": "^0.46.0",
"@opentelemetry/instrumentation": "^0.47.0",
"@opentelemetry/instrumentation-graphql": "^0.36.0",
"@opentelemetry/instrumentation-http": "^0.46.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/resources": "^1.19.0",
"@opentelemetry/sdk-metrics": "^1.19.0",
"@opentelemetry/sdk-node": "^0.46.0",
"@opentelemetry/sdk-trace-node": "^1.19.0",
"@opentelemetry/resources": "^1.20.0",
"@opentelemetry/sdk-metrics": "^1.20.0",
"@opentelemetry/sdk-node": "^0.47.0",
"@opentelemetry/sdk-trace-node": "^1.20.0",
"@opentelemetry/semantic-conventions": "^1.20.0",
"@prisma/client": "^5.7.1",
"@prisma/instrumentation": "^5.7.1",
"@socket.io/redis-adapter": "^8.2.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"dotenv-cli": "^7.3.0",
"express": "^4.18.2",
"file-type": "^19.0.0",
"get-stream": "^8.0.1",
"graphql": "^16.8.1",
"graphql-scalars": "^1.22.4",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^16.0.2",
"ioredis": "^5.3.2",
@@ -112,6 +117,7 @@
"typescript": "^5.3.2"
},
"ava": {
"timeout": "1m",
"extensions": {
"ts": "module"
},
@@ -0,0 +1,51 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const SELF_HOST_CONFIG_DIR = '/root/.affine/config';
/**
* @type {Array<{ from: string; to?: string, modifier?: (content: string): string }>}
*/
const configFiles = [
{ from: './.env.example', to: '.env' },
{ from: './dist/config/affine.js', modifier: configCleaner },
{ from: './dist/config/affine.env.js', modifier: configCleaner },
];
function configCleaner(content) {
return content.replace(/(\/\/#.*$)|(\/\/\s+TODO.*$)/gm, '');
}
function prepare() {
fs.mkdirSync(SELF_HOST_CONFIG_DIR, { recursive: true });
for (const { from, to, modifier } of configFiles) {
const targetFileName = to ?? path.parse(from).base;
const targetFilePath = path.join(SELF_HOST_CONFIG_DIR, targetFileName);
if (!fs.existsSync(targetFilePath)) {
console.log(`creating config file [${targetFilePath}].`);
if (modifier) {
const content = fs.readFileSync(from, 'utf-8');
fs.writeFileSync(targetFilePath, modifier(content), 'utf-8');
} else {
fs.cpSync(from, targetFilePath, {
force: false,
});
}
}
}
}
function runPredeployScript() {
console.log('running predeploy script.');
execSync('yarn predeploy', {
env: {
...process.env,
NODE_OPTIONS:
(process.env.NODE_OPTIONS ?? '') + ' --import ./dist/prelude.js',
},
});
}
prepare();
runPredeployScript();
-3
View File
@@ -1,3 +0,0 @@
import { getDefaultAFFiNEConfig } from './config/default';
globalThis.AFFiNE = getDefaultAFFiNEConfig();
@@ -1,13 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { Config } from './fundamentals/config';
@Controller('/')
export class AppController {
constructor(private readonly config: Config) {}
@Get()
info() {
const version = AFFiNE.version;
return {
compatibility: version,
message: `AFFiNE ${version} Server`,
compatibility: this.config.version,
message: `AFFiNE ${this.config.version} Server`,
flavor: this.config.flavor,
};
}
}
+169
View File
@@ -0,0 +1,169 @@
import { join } from 'node:path';
import { Logger, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { get } from 'lodash-es';
import { AppController } from './app.controller';
import { AuthModule } from './core/auth';
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
import { DocModule } from './core/doc';
import { FeatureModule } from './core/features';
import { QuotaModule } from './core/quota';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { UsersModule } from './core/users';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
import {
type AvailablePlugins,
Config,
ConfigModule,
} from './fundamentals/config';
import { EventModule } from './fundamentals/event';
import { GqlModule } from './fundamentals/graphql';
import { MailModule } from './fundamentals/mailer';
import { MetricsModule } from './fundamentals/metrics';
import { PrismaModule } from './fundamentals/prisma';
import { SessionModule } from './fundamentals/session';
import { RateLimiterModule } from './fundamentals/throttler';
import { WebSocketModule } from './fundamentals/websocket';
import { pluginsMap } from './plugins';
export const FunctionalityModules = [
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
EventModule,
CacheModule,
PrismaModule,
MetricsModule,
RateLimiterModule,
SessionModule,
MailModule,
];
export class AppModuleBuilder {
private readonly modules: AFFiNEModule[] = [];
constructor(private readonly config: Config) {}
use(...modules: AFFiNEModule[]): this {
modules.forEach(m => {
const requirements = getOptionalModuleMetadata(m, 'requires');
// if condition not set or condition met, include the module
if (requirements?.length) {
const nonMetRequirements = requirements.filter(c => {
const value = get(this.config, c);
return (
value === undefined ||
value === null ||
(typeof value === 'string' && value.trim().length === 0)
);
});
if (nonMetRequirements.length) {
const name = 'module' in m ? m.module.name : m.name;
new Logger(name).warn(
`${name} is not enabled because of the required configuration is not satisfied.`,
'Unsatisfied configuration:',
...nonMetRequirements.map(config => ` AFFiNE.${config}`)
);
return;
}
}
const predicator = getOptionalModuleMetadata(m, 'if');
if (predicator && !predicator(this.config)) {
return;
}
const contribution = getOptionalModuleMetadata(m, 'contributesTo');
if (contribution) {
ADD_ENABLED_FEATURES(contribution);
}
this.modules.push(m);
});
return this;
}
useIf(
predicator: (config: Config) => boolean,
...modules: AFFiNEModule[]
): this {
if (predicator(this.config)) {
this.use(...modules);
}
return this;
}
compile() {
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
],
imports: this.modules,
controllers: this.config.flavor.selfhosted ? [] : [AppController],
})
class AppModule {}
return AppModule;
}
}
function buildAppModule() {
const factor = new AppModuleBuilder(AFFiNE);
factor
// common fundamental modules
.use(...FunctionalityModules)
// auth
.use(AuthModule)
// business modules
.use(DocModule)
// sync server only
.useIf(config => config.flavor.sync, SyncModule)
// main server only
.useIf(
config => config.flavor.main,
ServerConfigModule,
WebSocketModule,
GqlModule,
StorageModule,
UsersModule,
WorkspaceModule,
FeatureModule,
QuotaModule
)
// self hosted server only
.useIf(
config => config.flavor.selfhosted,
ServeStaticModule.forRoot({
rootPath: join('/app', 'static'),
})
);
// plugin modules
AFFiNE.plugins.enabled.forEach(name => {
const plugin = pluginsMap.get(name as AvailablePlugins);
if (!plugin) {
throw new Error(`Unknown plugin ${name}`);
}
factor.use(plugin);
});
return factor.compile();
}
export const AppModule = buildAppModule();
+45 -31
View File
@@ -1,34 +1,48 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppController } from './app.controller';
import { CacheInterceptor, CacheModule } from './cache';
import { ConfigModule } from './config';
import { EventModule } from './event';
import { BusinessModules } from './modules';
import { AuthModule } from './modules/auth';
import { PrismaModule } from './prisma';
import { SessionModule } from './session';
import { RateLimiterModule } from './throttler';
import { SocketIoAdapter } from './fundamentals';
import { SocketIoAdapterImpl } from './fundamentals/websocket';
import { ExceptionLogger } from './middleware/exception-logger';
import { serverTimingAndCache } from './middleware/timing';
const BasicModules = [
PrismaModule,
ConfigModule.forRoot(),
CacheModule,
EventModule,
SessionModule,
RateLimiterModule,
AuthModule,
];
export async function createApp() {
const { AppModule } = await import('./app.module');
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
],
imports: [...BasicModules, ...BusinessModules],
controllers: [AppController],
})
export class AppModule {}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: true,
rawBody: true,
bodyParser: true,
logger: AFFiNE.affine.stable ? ['log'] : ['verbose'],
});
app.use(serverTimingAndCache);
app.use(
graphqlUploadExpress({
// TODO: dynamic limit by quota
maxFileSize: 100 * 1024 * 1024,
maxFiles: 5,
})
);
app.useGlobalFilters(new ExceptionLogger());
app.use(cookieParser());
if (AFFiNE.flavor.sync) {
const SocketIoAdapter = app.get<Type<SocketIoAdapter>>(
SocketIoAdapterImpl,
{
strict: false,
}
);
const adapter = new SocketIoAdapter(app);
app.useWebSocketAdapter(adapter);
}
return app;
}
-26
View File
@@ -1,26 +0,0 @@
import { FactoryProvider, Global, Module } from '@nestjs/common';
import { Redis } from 'ioredis';
import { Config } from '../config';
import { LocalCache } from './cache';
import { RedisCache } from './redis';
const CacheProvider: FactoryProvider = {
provide: LocalCache,
useFactory: (config: Config) => {
return config.redis.enabled
? new RedisCache(new Redis(config.redis))
: new LocalCache();
},
inject: [Config],
};
@Global()
@Module({
providers: [CacheProvider],
exports: [CacheProvider],
})
export class CacheModule {}
export { LocalCache as Cache };
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
@@ -0,0 +1,40 @@
// Convenient way to map environment variables to config values.
AFFiNE.ENV_MAP = {
AFFINE_SERVER_PORT: ['port', 'int'],
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFIHE_SERVER_HTTPS: ['https', 'boolean'],
AFFINE_ENV: 'affineEnv',
DATABASE_URL: 'db.url',
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
OAUTH_EMAIL_LOGIN: 'auth.email.login',
OAUTH_EMAIL_SENDER: 'auth.email.sender',
OAUTH_EMAIL_SERVER: 'auth.email.server',
OAUTH_EMAIL_PORT: ['auth.email.port', 'int'],
OAUTH_EMAIL_PASSWORD: 'auth.email.password',
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
REDIS_SERVER_HOST: 'plugins.redis.host',
REDIS_SERVER_PORT: ['plugins.redis.port', 'int'],
REDIS_SERVER_USER: 'plugins.redis.username',
REDIS_SERVER_PASSWORD: 'plugins.redis.password',
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
DOC_MERGE_USE_JWST_CODEC: [
'doc.manager.experimentalMergeWithJwstCodec',
'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'],
};
export default AFFiNE;
@@ -1,11 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
// Custom configurations
const env = process.env;
const node = AFFiNE.node;
// TODO: may be separate config overring in `affine.[env].config`?
if (node.prod && env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
// TODO(@forehalo): detail explained
// Storage
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.storage.providers.r2 = {
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
credentials: {
@@ -23,3 +22,18 @@ if (node.prod && env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
}
// Metrics
AFFiNE.metrics.enabled = true;
// Plugins Section Start
AFFiNE.plugins.use('payment', {
stripe: {
keys: {},
apiVersion: '2023-10-16',
},
});
AFFiNE.plugins.use('redis');
// Plugins Section end
export default AFFiNE;
-17
View File
@@ -1,17 +0,0 @@
import { set } from 'lodash-es';
import { type AFFiNEConfig, parseEnvValue } from './def';
export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
for (const env in rawConfig.ENV_MAP) {
const config = rawConfig.ENV_MAP[env];
const [path, value] =
typeof config === 'string'
? [config, process.env[env]]
: [config[0], parseEnvValue(process.env[env], config[1])];
if (value !== undefined) {
set(rawConfig, path, value);
}
}
}
-3
View File
@@ -1,3 +0,0 @@
export const OPERATION_NAME = 'x-operation-name';
export const REQUEST_ID = 'x-request-id';
@@ -10,8 +10,10 @@ import { Reflector } from '@nestjs/core';
import type { NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import {
getRequestResponseFromContext,
PrismaService,
} from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
@@ -1,7 +1,5 @@
import { Global, Module } from '@nestjs/common';
import { SessionModule } from '../../session';
import { MAILER, MailService } from './mailer';
import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options';
import { AuthResolver } from './resolver';
@@ -9,18 +7,12 @@ import { AuthService } from './service';
@Global()
@Module({
imports: [SessionModule],
providers: [
AuthService,
AuthResolver,
NextAuthOptionsProvider,
MAILER,
MailService,
],
exports: [AuthService, NextAuthOptionsProvider, MailService],
providers: [AuthService, AuthResolver, NextAuthOptionsProvider],
exports: [AuthService, NextAuthOptionsProvider],
controllers: [NextAuthController],
})
export class AuthModule {}
export * from './guard';
export { TokenType } from './resolver';
export { AuthService };
@@ -8,12 +8,14 @@ import Email from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { SessionService } from '../../session';
import {
Config,
MailService,
PrismaService,
SessionService,
} from '../../fundamentals';
import { FeatureType } from '../features';
import { Quota_FreePlanV1 } from '../quota';
import { MailService } from './mailer';
import { Quota_FreePlanV1_1 } from '../quota';
import {
decode,
encode,
@@ -50,7 +52,7 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
feature_version: Quota_FreePlanV1_1,
},
},
},
@@ -179,7 +181,7 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
);
}
if (config.auth.oauthProviders.google) {
if (config.auth.oauthProviders.google?.enabled) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Google.default({
@@ -22,11 +22,14 @@ import { nanoid } from 'nanoid';
import type { AuthAction, CookieOption, NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { Config } from '../../config';
import { metrics } from '../../metrics';
import { PrismaService } from '../../prisma/service';
import { SessionService } from '../../session';
import { AuthThrottlerGuard, Throttle } from '../../throttler';
import {
AuthThrottlerGuard,
Config,
metrics,
PrismaService,
SessionService,
Throttle,
} from '../../fundamentals';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
@@ -186,15 +189,17 @@ export class NextAuthController {
}
let nextAuthTokenCookie: (CookieOption & { value: string }) | undefined;
const cookiePrefix = this.config.node.prod ? '__Secure-' : '';
const sessionCookieName = `${cookiePrefix}next-auth.session-token`;
const secureCookiePrefix = '__Secure-';
const sessionCookieName = `next-auth.session-token`;
// next-auth credentials login only support JWT strategy
// https://next-auth.js.org/configuration/providers/credentials
// let's store the session token in the database
if (
credentialsSignIn &&
(nextAuthTokenCookie = cookies?.find(
({ name }) => name === sessionCookieName
({ name }) =>
name === sessionCookieName ||
name === `${secureCookiePrefix}${sessionCookieName}`
))
) {
const cookieExpires = new Date();
@@ -16,9 +16,12 @@ import {
import type { Request } from 'express';
import { nanoid } from 'nanoid';
import { Config } from '../../config';
import { SessionService } from '../../session';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import {
CloudThrottlerGuard,
Config,
SessionService,
Throttle,
} from '../../fundamentals';
import { UserType } from '../users';
import { Auth, CurrentUser } from './guard';
import { AuthService } from './service';
@@ -167,8 +170,13 @@ export class AuthResolver {
@CurrentUser() user: UserType,
@Args('token') token: string
) {
const key = await this.session.get(token);
if (!key) {
throw new ForbiddenException('Invalid token');
}
// email has set token in `sendVerifyChangeEmail`
const [id, email] = (await this.session.get(token)).split(',');
const [id, email] = key.split(',');
if (!id || id !== user.id || !email) {
throw new ForbiddenException('Invalid token');
}
@@ -11,11 +11,13 @@ import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { User } from '@prisma/client';
import { nanoid } from 'nanoid';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { verifyChallengeResponse } from '../../storage';
import { Quota_FreePlanV1 } from '../quota';
import { MailService } from './mailer';
import {
Config,
MailService,
PrismaService,
verifyChallengeResponse,
} from '../../fundamentals';
import { Quota_FreePlanV1_1 } from '../quota';
export type UserClaim = Pick<
User,
@@ -192,13 +194,14 @@ export class AuthService {
name,
email,
password: hashedPassword,
// TODO(@forehalo): handle in event system
features: {
create: {
reason: 'created by api sign up',
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
feature_version: Quota_FreePlanV1_1,
},
},
},
@@ -228,7 +231,7 @@ export class AuthService {
activated: true,
feature: {
connect: {
feature_version: Quota_FreePlanV1,
feature_version: Quota_FreePlanV1_1,
},
},
},
@@ -4,8 +4,7 @@ import { BadRequestException } from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { JWT } from 'next-auth/jwt';
import { Config } from '../../../config';
import { PrismaService } from '../../../prisma';
import { Config, PrismaService } from '../../../fundamentals';
import { getUtcTimestamp, UserClaim } from '../service';
export const jwtEncode = async (
@@ -2,9 +2,7 @@ import { Logger } from '@nestjs/common';
import { nanoid } from 'nanoid';
import type { SendVerificationRequestParams } from 'next-auth/providers/email';
import { Config } from '../../../config';
import { SessionService } from '../../../session';
import { MailService } from '../mailer';
import { Config, MailService, SessionService } from '../../../fundamentals';
export async function sendVerificationRequest(
config: Config,
@@ -0,0 +1,58 @@
import { Module } from '@nestjs/common';
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
export enum ServerFeature {
Payment = 'payment',
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
const ENABLED_FEATURES: ServerFeature[] = [];
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.push(feature);
}
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
}
export class ServerConfigResolver {
@Query(() => ServerConfigType, {
description: 'server config',
})
serverConfig(): ServerConfigType {
return {
name: AFFiNE.serverName,
version: AFFiNE.version,
baseUrl: AFFiNE.baseUrl,
flavor: AFFiNE.flavor.type,
features: ENABLED_FEATURES,
};
}
}
@Module({
providers: [ServerConfigResolver],
})
export class ServerConfigModule {}
@@ -3,10 +3,13 @@ import { isDeepStrictEqual } from 'node:util';
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Config } from '../../config';
import { type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics';
import { PrismaService } from '../../prisma';
import {
Config,
type EventPayload,
metrics,
OnEvent,
PrismaService,
} from '../../fundamentals';
import { QuotaService } from '../quota';
import { Permission } from '../workspaces/types';
import { isEmptyBuffer } from './manager';
@@ -17,12 +17,16 @@ import {
transact,
} from 'yjs';
import { Cache } from '../../cache';
import { Config } from '../../config';
import { EventEmitter, type EventPayload, OnEvent } from '../../event';
import { metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
import {
Cache,
Config,
EventEmitter,
type EventPayload,
mergeUpdatesInApplyWay as jwstMergeUpdates,
metrics,
OnEvent,
PrismaService,
} from '../../fundamentals';
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
if (yBinary.equals(jwstBinary)) {
@@ -1,4 +1,5 @@
import { PrismaService } from '../../prisma';
import { PrismaClient } from '@prisma/client';
import { Feature, FeatureSchema, FeatureType } from './types';
class FeatureConfig {
@@ -42,9 +43,22 @@ export class EarlyAccessFeatureConfig extends FeatureConfig {
}
}
export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
override config!: Feature & { feature: FeatureType.UnlimitedWorkspace };
constructor(data: any) {
super(data);
if (this.config.feature !== FeatureType.UnlimitedWorkspace) {
throw new Error('Invalid feature config: type is not EarlyAccess');
}
}
}
const FeatureConfigMap = {
[FeatureType.Copilot]: CopilotFeatureConfig,
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
[FeatureType.UnlimitedWorkspace]: UnlimitedWorkspaceFeatureConfig,
};
export type FeatureConfigType<F extends FeatureType> = InstanceType<
@@ -53,7 +67,7 @@ export type FeatureConfigType<F extends FeatureType> = InstanceType<
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
export async function getFeature(prisma: PrismaService, featureId: number) {
export async function getFeature(prisma: PrismaClient, featureId: number) {
const cachedQuota = FeatureCache.get(featureId);
if (cachedQuota) {
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { FeatureManagementService } from './management';
import { FeatureService } from './service';
@@ -18,4 +17,4 @@ export class FeatureModule {}
export { type CommonFeature, commonFeatureSchema } from './types';
export { FeatureKind, Features, FeatureType } from './types';
export { FeatureManagementService, FeatureService, PrismaService };
export { FeatureManagementService, FeatureService };
@@ -1,7 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { Config, PrismaService } from '../../fundamentals';
import { FeatureService } from './service';
import { FeatureType } from './types';
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { PrismaService } from '../../fundamentals';
import { UserType } from '../users/types';
import { WorkspaceType } from '../workspaces/types';
import { FeatureConfigType, getFeature } from './feature';
@@ -3,6 +3,7 @@ import { registerEnumType } from '@nestjs/graphql';
export enum FeatureType {
Copilot = 'copilot',
EarlyAccess = 'early_access',
UnlimitedWorkspace = 'unlimited_workspace',
}
registerEnumType(FeatureType, {
@@ -4,5 +4,8 @@ import { FeatureType } from './common';
export const featureEarlyAccess = z.object({
feature: z.literal(FeatureType.EarlyAccess),
configs: z.object({}),
configs: z.object({
// field polyfill, make it optional in the future
whitelist: z.string().array(),
}),
});
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { FeatureType } from './common';
import { featureCopilot } from './copilot';
import { featureEarlyAccess } from './early-access';
import { featureUnlimitedWorkspace } from './unlimited-workspace';
/// ======== common schema ========
@@ -41,6 +42,14 @@ export const Features: Feature[] = [
feature: FeatureType.EarlyAccess,
type: FeatureKind.Feature,
version: 2,
configs: {
whitelist: [],
},
},
{
feature: FeatureType.UnlimitedWorkspace,
type: FeatureKind.Feature,
version: 1,
configs: {},
},
];
@@ -51,7 +60,13 @@ export const FeatureSchema = commonFeatureSchema
.extend({
type: z.literal(FeatureKind.Feature),
})
.and(z.discriminatedUnion('feature', [featureCopilot, featureEarlyAccess]));
.and(
z.discriminatedUnion('feature', [
featureCopilot,
featureEarlyAccess,
featureUnlimitedWorkspace,
])
);
export type Feature = z.infer<typeof FeatureSchema>;
@@ -0,0 +1,8 @@
import { z } from 'zod';
import { FeatureType } from './common';
export const featureUnlimitedWorkspace = z.object({
feature: z.literal(FeatureType.UnlimitedWorkspace),
configs: z.object({}),
});
@@ -12,6 +12,7 @@ import { QuotaManagementService } from './storage';
* - quota statistics
*/
@Module({
// FIXME: Quota really need to know `Storage`?
imports: [StorageModule],
providers: [PermissionService, QuotaService, QuotaManagementService],
exports: [QuotaService, QuotaManagementService],
@@ -19,5 +20,5 @@ import { QuotaManagementService } from './storage';
export class QuotaModule {}
export { QuotaManagementService, QuotaService };
export { Quota_FreePlanV1, Quota_ProPlanV1, Quotas } from './schema';
export { QuotaType } from './types';
export { Quota_FreePlanV1_1, Quota_ProPlanV1, Quotas } from './schema';
export { QuotaQueryType, QuotaType } from './types';
@@ -1,4 +1,4 @@
import { PrismaService } from '../../prisma';
import { PrismaService } from '../../fundamentals';
import { formatDate, formatSize, Quota, QuotaSchema } from './types';
const QuotaCache = new Map<number, QuotaConfig>();
@@ -44,6 +44,10 @@ export class QuotaConfig {
}
}
get version() {
return this.config.version;
}
/// feature name of quota
get name() {
return this.config.feature;
@@ -37,11 +37,45 @@ export const Quotas: Quota[] = [
memberLimit: 10,
},
},
{
feature: QuotaType.RestrictedPlanV1,
type: FeatureKind.Quota,
version: 1,
configs: {
// quota name
name: 'Restricted',
// single blob limit 10MB
blobLimit: OneMB,
// total blob limit 1GB
storageQuota: 10 * OneMB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
},
},
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 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 = {
feature: Quotas[0].feature,
version: Quotas[0].version,
export const Quota_FreePlanV1_1 = {
feature: Quotas[3].feature,
version: Quotas[3].version,
};
export const Quota_ProPlanV1 = {
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { type EventPayload, OnEvent, PrismaService } from '../../fundamentals';
import { FeatureKind } from '../features';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';
type Transaction = Parameters<Parameters<PrismaService['$transaction']>[0]>[0];
@Injectable()
export class QuotaService {
constructor(private readonly prisma: PrismaService) {}
@@ -83,6 +85,13 @@ export class QuotaService {
expiredAt?: Date
) {
await this.prisma.$transaction(async tx => {
const hasSameActivatedQuota = await this.hasQuota(userId, quota, tx);
if (hasSameActivatedQuota) {
// don't need to switch
return;
}
const latestPlanVersion = await tx.features.aggregate({
where: {
feature: quota,
@@ -130,8 +139,10 @@ export class QuotaService {
});
}
async hasQuota(userId: string, quota: QuotaType) {
return this.prisma.userFeatures
async hasQuota(userId: string, quota: QuotaType, transaction?: Transaction) {
const executor = transaction ?? this.prisma;
return executor.userFeatures
.count({
where: {
userId,
@@ -144,4 +155,26 @@ export class QuotaService {
})
.then(count => count > 0);
}
@OnEvent('user.subscription.activated')
async onSubscriptionUpdated({
userId,
}: EventPayload<'user.subscription.activated'>) {
await this.switchUserQuota(
userId,
QuotaType.ProPlanV1,
'subscription activated'
);
}
@OnEvent('user.subscription.canceled')
async onSubscriptionCanceled(
userId: EventPayload<'user.subscription.canceled'>
) {
await this.switchUserQuota(
userId,
QuotaType.FreePlanV1,
'subscription canceled'
);
}
}
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { WorkspaceBlobStorage } from '../storage';
import { PermissionService } from '../workspaces/permission';
import { QuotaService } from './service';
import { QuotaQueryType } from './types';
@Injectable()
export class QuotaManagementService {
@@ -22,6 +23,9 @@ export class QuotaManagementService {
expiredAt: quota.expiredAt,
blobLimit: quota.feature.blobLimit,
storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit,
humanReadableName: quota.feature.humanReadable.name,
};
}
@@ -38,21 +42,22 @@ export class QuotaManagementService {
// get workspace's owner quota and total size of used
// quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string) {
async getWorkspaceUsage(workspaceId: string): Promise<QuotaQueryType> {
const { user: owner } =
await this.permissions.getWorkspaceOwner(workspaceId);
if (!owner) throw new NotFoundException('Workspace owner not found');
const { storageQuota } = await this.getUserQuota(owner.id);
const { humanReadableName, storageQuota, blobLimit } =
await this.getUserQuota(owner.id);
// get all workspaces size of owner used
const usageSize = await this.getUserUsage(owner.id);
const usedSize = await this.getUserUsage(owner.id);
return { quota: storageQuota, size: usageSize };
return { humanReadableName, storageQuota, usedSize, blobLimit };
}
async checkBlobQuota(workspaceId: string, size: number) {
const { quota, size: usageSize } =
const { storageQuota, usedSize } =
await this.getWorkspaceUsage(workspaceId);
return quota - (size + usageSize);
return storageQuota - (size + usedSize);
}
}
@@ -1,3 +1,5 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { z } from 'zod';
import { commonFeatureSchema, FeatureKind } from '../features';
@@ -8,10 +10,16 @@ import { ByteUnit, OneDay, OneKB } from './constant';
export enum QuotaType {
FreePlanV1 = 'free_plan_v1',
ProPlanV1 = 'pro_plan_v1',
// only for test, smaller quota
RestrictedPlanV1 = 'restricted_plan_v1',
}
const quotaPlan = z.object({
feature: z.enum([QuotaType.FreePlanV1, QuotaType.ProPlanV1]),
feature: z.enum([
QuotaType.FreePlanV1,
QuotaType.ProPlanV1,
QuotaType.RestrictedPlanV1,
]),
configs: z.object({
name: z.string(),
blobLimit: z.number().positive().int(),
@@ -31,6 +39,23 @@ export const QuotaSchema = commonFeatureSchema
export type Quota = z.infer<typeof QuotaSchema>;
/// ======== query types ========
@ObjectType()
export class QuotaQueryType {
@Field(() => String)
humanReadableName!: string;
@Field(() => SafeIntResolver)
storageQuota!: number;
@Field(() => SafeIntResolver)
usedSize!: number;
@Field(() => SafeIntResolver)
blobLimit!: number;
}
/// ======== utils ========
export function formatSize(bytes: number, decimals: number = 2): string {
@@ -1,18 +1,17 @@
import { Injectable } from '@nestjs/common';
import { AFFiNEStorageConfig, Config } from '../../../config';
import { type EventPayload, OnEvent } from '../../../event';
import {
import type {
BlobInputType,
createStorageProvider,
EventPayload,
PutObjectMetadata,
StorageProvider,
} from '../providers';
} from '../../../fundamentals';
import { Config, createStorageProvider, OnEvent } from '../../../fundamentals';
@Injectable()
export class AvatarStorage {
public readonly provider: StorageProvider;
private readonly storageConfig: AFFiNEStorageConfig['storages']['avatar'];
private readonly storageConfig: Config['storage']['storages']['avatar'];
constructor(private readonly config: Config) {
this.provider = createStorageProvider(this.config.storage, 'avatar');
@@ -1,27 +1,21 @@
import { Readable } from 'node:stream';
import { Injectable } from '@nestjs/common';
import type { Storage } from '@affine/storage';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Config } from '../../../config';
import { EventEmitter, type EventPayload, OnEvent } from '../../../event';
import { OctoBaseStorageModule } from '../../../storage';
import {
import type {
BlobInputType,
createStorageProvider,
EventPayload,
StorageProvider,
} from '../providers';
import { toBuffer } from '../providers/utils';
} from '../../../fundamentals';
import {
Config,
createStorageProvider,
EventEmitter,
OnEvent,
} from '../../../fundamentals';
@Injectable()
export class WorkspaceBlobStorage implements OnModuleInit {
export class WorkspaceBlobStorage {
public readonly provider: StorageProvider;
/**
* @deprecated for backwards compatibility, need to be removed in next stable release
*/
private octobase: Storage | null = null;
constructor(
private readonly event: EventEmitter,
private readonly config: Config
@@ -29,42 +23,12 @@ export class WorkspaceBlobStorage implements OnModuleInit {
this.provider = createStorageProvider(this.config.storage, 'blob');
}
async onModuleInit() {
if (!this.config.node.test) {
this.octobase = await OctoBaseStorageModule.Storage.connect(
this.config.db.url
);
}
}
async put(workspaceId: string, key: string, blob: BlobInputType) {
const buf = await toBuffer(blob);
await this.provider.put(`${workspaceId}/${key}`, buf);
if (this.octobase) {
await this.octobase.uploadBlob(workspaceId, buf);
}
await this.provider.put(`${workspaceId}/${key}`, blob);
}
async get(workspaceId: string, key: string) {
const result = await this.provider.get(`${workspaceId}/${key}`);
if (!result.body && this.octobase) {
const blob = await this.octobase.getBlob(workspaceId, key);
if (!blob) {
return result;
}
return {
body: Readable.from(blob.data),
metadata: {
contentType: blob.contentType,
contentLength: blob.size,
lastModified: new Date(blob.lastModified),
},
};
}
return result;
return this.provider.get(`${workspaceId}/${key}`);
}
async list(workspaceId: string) {
@@ -11,12 +11,11 @@ import {
import { Server, Socket } from 'socket.io';
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { metrics } from '../../../metrics';
import { CallTimer } from '../../../metrics/utils';
import { DocID } from '../../../utils/doc';
import { CallTimer, metrics } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../../workspaces/permission';
import { Permission } from '../../workspaces/types';
import {
@@ -5,7 +5,7 @@ import {
} from '@nestjs/common';
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { Auth, CurrentUser } from '../auth/guard';
import { AuthService } from '../auth/service';
import { FeatureManagementService } from '../features';
@@ -11,10 +11,13 @@ import type { User } from '@prisma/client';
import { GraphQLError } from 'graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { EventEmitter } from '../../event';
import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
PrismaService,
Throttle,
} from '../../fundamentals';
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { FeatureManagementService } from '../features';
import { QuotaService } from '../quota';
@@ -1,5 +1,6 @@
import { createUnionType, Field, Float, ID, ObjectType } from '@nestjs/graphql';
import { createUnionType, Field, ID, ObjectType } from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
@ObjectType('UserQuotaHumanReadable')
export class UserQuotaHumanReadableType {
@@ -24,13 +25,13 @@ export class UserQuotaType {
@Field({ name: 'name' })
name!: string;
@Field(() => Float, { name: 'blobLimit' })
@Field(() => SafeIntResolver, { name: 'blobLimit' })
blobLimit!: number;
@Field(() => Float, { name: 'storageQuota' })
@Field(() => SafeIntResolver, { name: 'storageQuota' })
storageQuota!: number;
@Field(() => Float, { name: 'historyPeriod' })
@Field(() => SafeIntResolver, { name: 'historyPeriod' })
historyPeriod!: number;
@Field({ name: 'memberLimit' })
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { PrismaService } from '../../fundamentals';
@Injectable()
export class UsersService {
@@ -9,13 +9,12 @@ import {
} from '@nestjs/common';
import type { Response } from 'express';
import { CallTimer } from '../../metrics';
import { PrismaService } from '../../prisma';
import { DocID } from '../../utils/doc';
import { CallTimer, PrismaService } from '../../fundamentals';
import { Auth, CurrentUser, Publicable } from '../auth';
import { DocHistoryManager, DocManager } from '../doc';
import { WorkspaceBlobStorage } from '../storage';
import { UserType } from '../users';
import { DocID } from '../utils/doc';
import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types';
@@ -9,7 +9,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { Auth, CurrentUser } from '../auth';
import { FeatureManagementService, FeatureType } from '../features';
import { UserType } from '../users';
@@ -1,7 +1,7 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma';
import { PrismaService } from '../../fundamentals';
import { Permission } from './types';
export enum PublicPageMode {
@@ -1,7 +1,6 @@
import { ForbiddenException, Logger, UseGuards } from '@nestjs/common';
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
import {
Args,
Float,
Int,
Mutation,
Parent,
@@ -9,12 +8,18 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { MakeCache, PreventCache } from '../../../cache';
import { CloudThrottlerGuard } from '../../../throttler';
import type { FileUpload } from '../../../types';
import {
CloudThrottlerGuard,
type FileUpload,
MakeCache,
PreventCache,
} from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UserType } from '../../users';
@@ -28,10 +33,26 @@ export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name);
constructor(
private readonly permissions: PermissionService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaManagementService,
private readonly storage: WorkspaceBlobStorage
) {}
@ResolveField(() => [String], {
description: 'List blobs of workspace',
complexity: 2,
})
async blobs(
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
await this.permissions.checkWorkspace(workspace.id, user.id);
return this.storage
.list(workspace.id)
.then(list => list.map(item => item.key));
}
@ResolveField(() => Int, {
description: 'Blobs size of workspace',
complexity: 2,
@@ -79,7 +100,7 @@ export class WorkspaceBlobResolver {
async checkBlobSize(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('size', { type: () => Float }) blobSize: number
@Args('size', { type: () => SafeIntResolver }) blobSize: number
) {
const canWrite = await this.permissions.tryCheckWorkspace(
workspaceId,
@@ -107,24 +128,45 @@ export class WorkspaceBlobResolver {
Permission.Write
);
const { quota, size } = await this.quota.getWorkspaceUsage(workspaceId);
const { storageQuota, usedSize, blobLimit } =
await this.quota.getWorkspaceUsage(workspaceId);
const unlimited = await this.feature.hasWorkspaceFeature(
workspaceId,
FeatureType.UnlimitedWorkspace
);
const checkExceeded = (recvSize: number) => {
if (!quota) {
throw new ForbiddenException('cannot find user quota');
if (!storageQuota) {
throw new GraphQLError('cannot find user quota', {
extensions: {
status: HttpStatus[HttpStatus.FORBIDDEN],
code: HttpStatus.FORBIDDEN,
},
});
}
if (size + recvSize > quota) {
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
if (total > storageQuota && !unlimited) {
this.logger.log(
`storage size limit exceeded: ${size + recvSize} > ${quota}`
`storage size limit exceeded: ${total} > ${storageQuota}`
);
return true;
} else if (recvSize > blobLimit) {
this.logger.log(`blob size limit exceeded: ${recvSize} > ${blobLimit}`);
return true;
} else {
return false;
}
};
if (checkExceeded(0)) {
throw new ForbiddenException('storage size limit exceeded');
throw new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
});
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -135,7 +177,14 @@ export class WorkspaceBlobResolver {
// check size after receive each chunk to avoid unnecessary memory usage
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(new ForbiddenException('storage size limit exceeded'));
reject(
new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
);
}
});
stream.on('error', reject);
@@ -143,17 +192,20 @@ export class WorkspaceBlobResolver {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new ForbiddenException('storage size limit exceeded'));
reject(
new GraphQLError('storage limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
);
} else {
resolve(buffer);
}
});
});
if (!(await this.quota.checkBlobQuota(workspaceId, buffer.length))) {
throw new ForbiddenException('blob size limit exceeded');
}
await this.storage.put(workspaceId, blob.filename, buffer);
return blob.filename;
}
@@ -12,11 +12,11 @@ import {
} from '@nestjs/graphql';
import type { SnapshotHistory } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../throttler';
import { DocID } from '../../../utils/doc';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { DocHistoryManager } from '../../doc/history';
import { DocHistoryManager } from '../../doc';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -11,11 +11,10 @@ import {
} from '@nestjs/graphql';
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { PrismaService } from '../../../prisma';
import { CloudThrottlerGuard } from '../../../throttler';
import { DocID } from '../../../utils/doc';
import { CloudThrottlerGuard, PrismaService } from '../../../fundamentals';
import { Auth, CurrentUser } from '../../auth';
import { UserType } from '../../users';
import { DocID } from '../../utils/doc';
import { PermissionService, PublicPageMode } from '../permission';
import { Permission, WorkspaceType } from '../types';
@@ -1,6 +1,6 @@
import {
ForbiddenException,
InternalServerErrorException,
HttpStatus,
Logger,
NotFoundException,
UseGuards,
@@ -16,16 +16,22 @@ 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';
import { EventEmitter } from '../../../event';
import { PrismaService } from '../../../prisma';
import { CloudThrottlerGuard, Throttle } from '../../../throttler';
import type { FileUpload } from '../../../types';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
MailService,
PrismaService,
Throttle,
} from '../../../fundamentals';
import { Auth, CurrentUser, Public } from '../../auth';
import { MailService } from '../../auth/mailer';
import { AuthService } from '../../auth/service';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UsersService, UserType } from '../../users';
import { PermissionService } from '../permission';
@@ -54,6 +60,8 @@ 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,
private readonly blobStorage: WorkspaceBlobStorage
@@ -141,6 +149,15 @@ export class WorkspaceResolver {
}));
}
@ResolveField(() => QuotaQueryType, {
name: 'quota',
description: 'quota of workspace',
complexity: 2,
})
workspaceQuota(@Parent() workspace: WorkspaceType) {
return this.quota.getWorkspaceUsage(workspace.id);
}
@Query(() => Boolean, {
description: 'Get is owner of workspace',
complexity: 2,
@@ -321,8 +338,29 @@ export class WorkspaceResolver {
throw new ForbiddenException('Cannot change owner');
}
const target = await this.users.findUserByEmail(email);
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,
},
});
}
}
let target = await this.users.findUserByEmail(email);
if (target) {
const originRecord = await this.prisma.workspaceUserPermission.findFirst({
where: {
@@ -330,94 +368,59 @@ export class WorkspaceResolver {
userId: target.id,
},
});
if (originRecord) {
return originRecord.id;
}
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
target.id
);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(e);
}
}
return inviteId;
// only invite if the user is not already in the workspace
if (originRecord) return originRecord.id;
} else {
const user = await this.auth.createAnonymousUser(email);
const inviteId = await this.permissions.grant(
workspaceId,
user.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
user.id
);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new InternalServerErrorException(e);
}
}
return inviteId;
target = await this.auth.createAnonymousUser(email);
}
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
target.id
);
if (!ret) {
this.logger.fatal(
`failed to send ${workspaceId} invite email to ${email} and failed to revoke permission: ${inviteId}, ${e}`
);
} else {
this.logger.warn(
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new GraphQLError(
'failed to send invite email, please try again',
{
extensions: {
status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
code: HttpStatus.INTERNAL_SERVER_ERROR,
},
}
);
}
}
return inviteId;
}
@Throttle({
@@ -1,6 +1,5 @@
import {
Field,
Float,
ID,
InputType,
ObjectType,
@@ -10,6 +9,7 @@ import {
registerEnumType,
} from '@nestjs/graphql';
import type { Workspace } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { UserType } from '../users/types';
@@ -78,7 +78,7 @@ export class InvitationWorkspaceType {
@ObjectType()
export class WorkspaceBlobSizes {
@Field(() => Float)
@Field(() => SafeIntResolver)
size!: number;
}
+7 -15
View File
@@ -1,8 +1,7 @@
import { Logger, Module } from '@nestjs/common';
import { CommandFactory } from 'nest-commander';
import { Module } from '@nestjs/common';
import { AppModule as BusinessAppModule } from '../app';
import { ConfigModule } from '../config';
import { AppModule as BusinessAppModule } from '../app.module';
import { ConfigModule } from '../fundamentals/config';
import { CreateCommand, NameQuestion } from './commands/create';
import { RevertCommand, RunCommand } from './commands/run';
@@ -14,19 +13,12 @@ import { RevertCommand, RunCommand } from './commands/run';
enableUpdateAutoMerging: false,
},
},
metrics: {
enabled: false,
},
}),
BusinessAppModule,
],
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
})
class AppModule {}
async function bootstrap() {
await CommandFactory.run(AppModule, new Logger()).catch(e => {
console.error(e);
process.exit(1);
});
process.exit(0);
}
await bootstrap();
export class CliAppModule {}
@@ -59,13 +59,13 @@ export class CreateCommand extends CommandRunner {
}
private createScript(name: string) {
const contents = ["import { PrismaService } from '../../prisma';", ''];
const contents = ["import { PrismaClient } from '@prisma/client';", ''];
contents.push(`export class ${name} {`);
contents.push(' // do the migration');
contents.push(' static async up(db: PrismaService) {}');
contents.push(' static async up(db: PrismaClient) {}');
contents.push('');
contents.push(' // revert the migration');
contents.push(' static async down(db: PrismaService) {}');
contents.push(' static async down(db: PrismaClient) {}');
contents.push('}');
@@ -4,15 +4,14 @@ import { fileURLToPath } from 'node:url';
import { Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { Command, CommandRunner } from 'nest-commander';
import { PrismaService } from '../../prisma';
interface Migration {
file: string;
name: string;
up: (db: PrismaService, injector: ModuleRef) => Promise<void>;
down: (db: PrismaService, injector: ModuleRef) => Promise<void>;
up: (db: PrismaClient, injector: ModuleRef) => Promise<void>;
down: (db: PrismaClient, injector: ModuleRef) => Promise<void>;
}
export async function collectMigrations(): Promise<Migration[]> {
@@ -48,7 +47,7 @@ export async function collectMigrations(): Promise<Migration[]> {
export class RunCommand extends CommandRunner {
logger = new Logger(RunCommand.name);
constructor(
private readonly db: PrismaService,
private readonly db: PrismaClient,
private readonly injector: ModuleRef
) {
super();
@@ -139,7 +138,7 @@ export class RevertCommand extends CommandRunner {
logger = new Logger(RevertCommand.name);
constructor(
private readonly db: PrismaService,
private readonly db: PrismaClient,
private readonly injector: ModuleRef
) {
super();
+16
View File
@@ -0,0 +1,16 @@
import '../prelude';
import { Logger } from '@nestjs/common';
import { CommandFactory } from 'nest-commander';
import { CliAppModule } from './app';
async function bootstrap() {
await CommandFactory.run(CliAppModule, new Logger()).catch(e => {
console.error(e);
process.exit(1);
});
process.exit(0);
}
await bootstrap();
@@ -0,0 +1,39 @@
import { ModuleRef } from '@nestjs/core';
import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
import { Config } from '../../fundamentals';
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 (
!process.env.AFFINE_ADMIN_EMAIL ||
!process.env.AFFINE_ADMIN_PASSWORD
) {
throw new Error(
'You have to set AFFINE_ADMIN_EMAIL and AFFINE_ADMIN_PASSWORD environment variables to generate the initial user for self-hosted AFFiNE Server.'
);
}
await db.user.create({
data: {
name: 'AFFINE First User',
email: process.env.AFFINE_ADMIN_EMAIL,
emailVerified: new Date(),
password: await hash(process.env.AFFINE_ADMIN_PASSWORD),
},
});
}
}
// revert the migration
static async down(db: PrismaClient) {
await db.user.deleteMany({
where: {
email: process.env.AFFINE_ADMIN_EMAIL ?? 'admin@example.com',
},
});
}
}
@@ -1,11 +1,11 @@
import { PrismaClient } from '@prisma/client';
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
import { PrismaService } from '../../prisma';
import { DocID } from '../../utils/doc';
import { DocID } from '../../core/utils/doc';
export class Guid1698398506533 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
let turn = 0;
let lastTurnCount = 100;
while (lastTurnCount === 100) {
@@ -1,11 +1,12 @@
import { Features } from '../../modules/features';
import { Quotas } from '../../modules/quota/schema';
import { PrismaService } from '../../prisma';
import { PrismaClient } from '@prisma/client';
import { Features } from '../../core/features';
import { Quotas } from '../../core/quota/schema';
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
export class UserFeaturesInit1698652531198 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
// upgrade features from lower version to higher version
for (const feature of Features) {
await upsertFeature(db, feature);
@@ -18,7 +19,7 @@ export class UserFeaturesInit1698652531198 {
}
// revert the migration
static async down(_db: PrismaService) {
static async down(_db: PrismaClient) {
// TODO: revert the migration
}
}
@@ -1,8 +1,7 @@
import { PrismaService } from '../../prisma';
import { PrismaClient } from '@prisma/client';
export class PagePermission1699005339766 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
let turn = 0;
let lastTurnCount = 50;
const done = new Set<string>();
@@ -88,7 +87,7 @@ export class PagePermission1699005339766 {
}
// revert the migration
static async down(db: PrismaService) {
static async down(db: PrismaClient) {
await db.workspaceUserPermission.deleteMany({});
await db.workspacePageUserPermission.deleteMany({});
}
@@ -1,9 +1,9 @@
import { QuotaType } from '../../modules/quota/types';
import { PrismaService } from '../../prisma';
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
export class OldUserFeature1702620653283 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
await db.$transaction(async tx => {
const latestFreePlan = await tx.features.findFirstOrThrow({
where: { feature: QuotaType.FreePlanV1 },
@@ -16,7 +16,6 @@ export class OldUserFeature1702620653283 {
where: { NOT: { features: { some: { NOT: { id: { gt: 0 } } } } } },
select: { id: true },
});
console.log(`migrating ${userIds.join('|')} users`);
await tx.userFeatures.createMany({
data: userIds.map(({ id: userId }) => ({
@@ -31,7 +30,7 @@ export class OldUserFeature1702620653283 {
// revert the migration
// WARN: this will drop all user features
static async down(db: PrismaService) {
static async down(db: PrismaClient) {
await db.userFeatures.deleteMany({});
}
}
@@ -1,13 +1,12 @@
import type { UserType } from '../../modules/users';
import { PrismaService } from '../../prisma';
import { PrismaClient, type User } from '@prisma/client';
export class UnamedAccount1703756315970 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
await db.$transaction(async tx => {
// only find users with empty names
const users = await db.$queryRaw<
UserType[]
User[]
>`SELECT * FROM users WHERE name ~ E'^[\\s\\u2000-\\u200F]*$';`;
console.log(
`renaming ${users.map(({ email }) => email).join('|')} users`
@@ -27,5 +26,5 @@ export class UnamedAccount1703756315970 {
}
// revert the migration
static async down(_db: PrismaService) {}
static async down(_db: PrismaClient) {}
}
@@ -1,11 +1,11 @@
import { ModuleRef } from '@nestjs/core';
import { PrismaClient } from '@prisma/client';
import { WorkspaceBlobStorage } from '../../modules/storage';
import { PrismaService } from '../../prisma';
import { WorkspaceBlobStorage } from '../../core/storage';
export class WorkspaceBlobs1703828796699 {
// do the migration
static async up(db: PrismaService, injector: ModuleRef) {
static async up(db: PrismaClient, injector: ModuleRef) {
const blobStorage = injector.get(WorkspaceBlobStorage, { strict: false });
let hasMore = true;
let turn = 0;
@@ -32,7 +32,7 @@ export class WorkspaceBlobs1703828796699 {
}
// revert the migration
static async down(_db: PrismaService) {
static async down(_db: PrismaClient) {
// old data kept, no need to downgrade the migration
}
}
@@ -1,10 +1,11 @@
import { Features } from '../../modules/features';
import { PrismaService } from '../../prisma';
import { PrismaClient } from '@prisma/client';
import { Features } from '../../core/features';
import { upsertFeature } from './utils/user-features';
export class RefreshUserFeatures1704352562369 {
// do the migration
static async up(db: PrismaService) {
static async up(db: PrismaClient) {
// add early access v2 & copilot feature
for (const feature of Features) {
await upsertFeature(db, feature);
@@ -12,5 +13,5 @@ export class RefreshUserFeatures1704352562369 {
}
// revert the migration
static async down(_db: PrismaService) {}
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,67 @@
import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../core/features';
import { Quotas } from '../../core/quota';
import { upsertFeature } from './utils/user-features';
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,
})),
});
});
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -1,15 +1,14 @@
import { Prisma } from '@prisma/client';
import { Prisma, PrismaClient } from '@prisma/client';
import {
CommonFeature,
FeatureKind,
FeatureType,
} from '../../../modules/features';
import { PrismaService } from '../../../prisma';
} from '../../../core/features';
// upgrade features from lower version to higher version
export async function upsertFeature(
db: PrismaService,
db: PrismaClient,
feature: CommonFeature
): Promise<void> {
const hasEqualOrGreaterVersion =
@@ -34,7 +33,7 @@ export async function upsertFeature(
}
}
export async function migrateNewFeatureTable(prisma: PrismaService) {
export async function migrateNewFeatureTable(prisma: PrismaClient) {
const waitingList = await prisma.newFeaturesWaitingList.findMany();
for (const oldUser of waitingList) {
const user = await prisma.user.findFirst({
@@ -1,33 +0,0 @@
import type { Snapshot, User, Workspace } from '@prisma/client';
import { Flatten, Payload } from './types';
interface EventDefinitions {
workspace: {
deleted: Payload<Workspace['id']>;
blob: {
deleted: Payload<{
workspaceId: Workspace['id'];
name: string;
}>;
};
};
snapshot: {
updated: Payload<
Pick<Snapshot, 'id' | 'workspaceId'> & {
previous: Pick<Snapshot, 'blob' | 'state' | 'updatedAt'>;
}
>;
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
};
user: {
deleted: Payload<User>;
};
}
export type EventKV = Flatten<EventDefinitions>;
export type Event = keyof EventKV;
export type EventPayload<E extends Event> = EventKV[E];
+51
View File
@@ -0,0 +1,51 @@
export interface CacheSetOptions {
/**
* in milliseconds
*/
ttl?: number;
}
// extends if needed
export interface Cache {
// standard operation
get<T = unknown>(key: string): Promise<T | undefined>;
set<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
setnx<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
increase(key: string, count?: number): Promise<number>;
decrease(key: string, count?: number): Promise<number>;
delete(key: string): Promise<boolean>;
has(key: string): Promise<boolean>;
ttl(key: string): Promise<number>;
expire(key: string, ttl: number): Promise<boolean>;
// list operations
pushBack<T = unknown>(key: string, ...values: T[]): Promise<number>;
pushFront<T = unknown>(key: string, ...values: T[]): Promise<number>;
len(key: string): Promise<number>;
list<T = unknown>(key: string, start: number, end: number): Promise<T[]>;
popFront<T = unknown>(key: string, count?: number): Promise<T[]>;
popBack<T = unknown>(key: string, count?: number): Promise<T[]>;
// map operations
mapSet<T = unknown>(
map: string,
key: string,
value: T,
opts: CacheSetOptions
): Promise<boolean>;
mapIncrease(map: string, key: string, count?: number): Promise<number>;
mapDecrease(map: string, key: string, count?: number): Promise<number>;
mapGet<T = unknown>(map: string, key: string): Promise<T | undefined>;
mapDelete(map: string, key: string): Promise<boolean>;
mapKeys(map: string): Promise<string[]>;
mapRandomKey(map: string): Promise<string | undefined>;
mapLen(map: string): Promise<number>;
}
+13
View File
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { Cache, SessionCache } from './instances';
@Global()
@Module({
providers: [Cache, SessionCache],
exports: [Cache, SessionCache],
})
export class CacheModule {}
export { Cache, SessionCache };
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';

Some files were not shown because too many files have changed in this diff Show More