mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-05 09:04:56 +00:00
Compare commits
2 Commits
v0.12.1
...
v0.11.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55792e2f41 | ||
|
|
04e7a9fc14 |
@@ -11,5 +11,6 @@ e2e-dist-*
|
||||
static
|
||||
web-static
|
||||
public
|
||||
packages/common/sdk/src/*.d.ts
|
||||
packages/common/sdk/src/*.js
|
||||
packages/frontend/i18n/src/i18n-generated.ts
|
||||
packages/frontend/templates/edgeless-templates.gen.ts
|
||||
|
||||
@@ -61,6 +61,7 @@ const allPackages = [
|
||||
'packages/frontend/core',
|
||||
'packages/frontend/electron',
|
||||
'packages/frontend/graphql',
|
||||
'packages/frontend/hooks',
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/native',
|
||||
'packages/frontend/templates',
|
||||
@@ -68,8 +69,10 @@ const allPackages = [
|
||||
'packages/common/debug',
|
||||
'packages/common/env',
|
||||
'packages/common/infra',
|
||||
'packages/common/sdk',
|
||||
'packages/common/theme',
|
||||
'packages/common/y-indexeddb',
|
||||
'packages/plugins/copilot',
|
||||
'tools/cli',
|
||||
'tests/storybook',
|
||||
];
|
||||
@@ -217,7 +220,6 @@ 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',
|
||||
|
||||
30
.github/CLA.md
vendored
30
.github/CLA.md
vendored
@@ -33,6 +33,32 @@ You accept and agree to the following terms and conditions for your past, presen
|
||||
|
||||
9. This Agreement will be governed by the laws of Republic of Singapore without reference to conflict of laws principles.
|
||||
|
||||
## How To Sign
|
||||
## List of Contributors
|
||||
|
||||
Visit https://cla-assistant.io/toeverything/AFFiNE and sign it.
|
||||
The below-signed are contributors to a code repository that is part of the project named "AFFiNE". Each below-signed contributor has read, understand and agrees to the terms above in the section within this document entitled "AFFiNE Contributor License Agreement" as of the date beside their real name (or entity name) and GitHub account name.
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
Example:
|
||||
|
||||
- Dark Sky, @darkskygit, 2022/07/22
|
||||
-->
|
||||
|
||||
- Dark Sky, @darkskygit, 2022/07/22
|
||||
- Lin Onetwo, @linonetwo, 2022/02/14
|
||||
- zqran, @zqran, 2023/02/17
|
||||
- Alessio Gravili, @AlessioGr, 2023/03/04
|
||||
- Victor Nanka, @victornanka, 2023/03/09
|
||||
- Aditya Sharma, @adityash1, 2023/03/21
|
||||
- Fangdun Tsai, @fundon, 2023/03/21
|
||||
- Zhilin Liu, @lzlme, 2023/04/09
|
||||
- Skye Sun, @skyesun, 2023/04/14
|
||||
- Jordy Delgado, @Jdelgad8, 2023/04/17
|
||||
- Howard Do, @howarddo2208, 2023/04/20
|
||||
- 三咲智子 Kevin Deng, @sxzz, 2023/04/21
|
||||
- Moeyua, @moeyua, 2023/04/22
|
||||
- Shishu, @shishudesu, 2023/05/19
|
||||
- Kushagra Singh, @kush002, 2023/06/28
|
||||
- Sarvesh Kumar, @sarvesh521 2023/08/25
|
||||
- 微扰理论 Qinghao Huang, @wfnuser 2023/09/29
|
||||
|
||||
2
.github/actions/build-rust/action.yml
vendored
2
.github/actions/build-rust/action.yml
vendored
@@ -37,7 +37,7 @@ runs:
|
||||
echo "TARGET_CC=clang" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
|
||||
14
.github/actions/deploy/deploy.mjs
vendored
14
.github/actions/deploy/deploy.mjs
vendored
@@ -13,6 +13,7 @@ const {
|
||||
R2_ACCOUNT_ID,
|
||||
R2_ACCESS_KEY_ID,
|
||||
R2_SECRET_ACCESS_KEY,
|
||||
R2_BUCKET,
|
||||
ENABLE_CAPTCHA,
|
||||
CAPTCHA_TURNSTILE_SECRET,
|
||||
OAUTH_EMAIL_SENDER,
|
||||
@@ -65,16 +66,8 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
]
|
||||
: [];
|
||||
const webReplicaCount = isProduction ? 3 : isBeta ? 2 : 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 graphqlReplicaCount = isProduction ? 10 : isBeta ? 5 : 2;
|
||||
const syncReplicaCount = isProduction ? 10 : isBeta ? 5 : 2;
|
||||
const namespace = isProduction
|
||||
? 'production'
|
||||
: isBeta
|
||||
@@ -103,6 +96,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
|
||||
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
|
||||
`--set-string graphql.app.objectStorage.r2.secretAccessKey="${R2_SECRET_ACCESS_KEY}"`,
|
||||
`--set-string graphql.app.objectStorage.r2.bucket="${R2_BUCKET}"`,
|
||||
`--set-string graphql.app.oauth.email.sender="${OAUTH_EMAIL_SENDER}"`,
|
||||
`--set-string graphql.app.oauth.email.login="${OAUTH_EMAIL_LOGIN}"`,
|
||||
`--set-string graphql.app.oauth.email.password="${OAUTH_EMAIL_PASSWORD}"`,
|
||||
|
||||
2
.github/actions/download-core/action.yml
vendored
2
.github/actions/download-core/action.yml
vendored
@@ -9,7 +9,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Download tar.gz
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: .
|
||||
|
||||
30
.github/actions/setup-node/action.yml
vendored
30
.github/actions/setup-node/action.yml
vendored
@@ -21,6 +21,14 @@ inputs:
|
||||
description: 'set nmMode to hardlinks-local in .yarnrc.yml'
|
||||
required: false
|
||||
default: 'true'
|
||||
build-infra:
|
||||
description: 'Build infra'
|
||||
required: false
|
||||
default: 'true'
|
||||
build-plugins:
|
||||
description: 'Build plugins'
|
||||
required: false
|
||||
default: 'true'
|
||||
nmHoistingLimits:
|
||||
description: 'Set nmHoistingLimits in .yarnrc.yml'
|
||||
required: false
|
||||
@@ -63,7 +71,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@v4
|
||||
uses: actions/cache@v3
|
||||
if: ${{ inputs.full-cache != 'true' && runner.os == 'Linux' }}
|
||||
with:
|
||||
path: |
|
||||
@@ -75,7 +83,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@v4
|
||||
uses: actions/cache@v3
|
||||
if: ${{ inputs.full-cache != 'true' && runner.os != 'Linux' }}
|
||||
with:
|
||||
path: |
|
||||
@@ -83,7 +91,7 @@ runs:
|
||||
key: node_modules-cache-${{ github.job }}-${{ runner.os }}
|
||||
|
||||
- name: Cache full yarn cache on Linux
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
if: ${{ inputs.full-cache == 'true' && runner.os == 'Linux' }}
|
||||
with:
|
||||
path: |
|
||||
@@ -92,7 +100,7 @@ runs:
|
||||
key: node_modules-cache-full-${{ runner.os }}
|
||||
|
||||
- name: Cache full yarn cache on non-Linux
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v3
|
||||
if: ${{ inputs.full-cache == 'true' && runner.os != 'Linux' }}
|
||||
with:
|
||||
path: |
|
||||
@@ -134,7 +142,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@v4
|
||||
- uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
if: ${{ inputs.playwright-install == 'true' }}
|
||||
with:
|
||||
@@ -167,7 +175,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@v4
|
||||
- uses: actions/cache@v3
|
||||
id: electron-cache
|
||||
if: ${{ inputs.electron-install == 'true' }}
|
||||
with:
|
||||
@@ -182,3 +190,13 @@ runs:
|
||||
run: node ./node_modules/electron/install.js
|
||||
env:
|
||||
electron_config_cache: ./node_modules/.cache/electron
|
||||
|
||||
- name: Build Infra
|
||||
shell: bash
|
||||
if: inputs.build-infra == 'true'
|
||||
run: yarn run build:infra
|
||||
|
||||
- name: Build Plugins
|
||||
if: inputs.build-plugins == 'true'
|
||||
shell: bash
|
||||
run: yarn run build:plugins
|
||||
|
||||
4
.github/actions/setup-version/action.yml
vendored
4
.github/actions/setup-version/action.yml
vendored
@@ -1,9 +1,5 @@
|
||||
name: Setup Version
|
||||
description: 'Setup Version'
|
||||
outputs:
|
||||
APP_VERSION:
|
||||
description: 'App Version'
|
||||
value: ${{ steps.version.outputs.APP_VERSION }}
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
|
||||
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -1,4 +1,4 @@
|
||||
FROM openresty/openresty:1.21.4.3-0-buster
|
||||
FROM openresty/openresty:1.21.4.1-0-buster
|
||||
WORKDIR /app
|
||||
COPY ./packages/frontend/core/dist ./dist
|
||||
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||
|
||||
1
.github/deployment/node/Dockerfile
vendored
1
.github/deployment/node/Dockerfile
vendored
@@ -1,7 +1,6 @@
|
||||
FROM node:18-bookworm-slim
|
||||
|
||||
COPY ./packages/backend/server /app
|
||||
COPY ./packages/frontend/core/dist /app/static
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
|
||||
59
.github/deployment/self-host/compose.yaml
vendored
59
.github/deployment/self-host/compose.yaml
vendored
@@ -1,59 +0,0 @@
|
||||
services:
|
||||
affine:
|
||||
image: ghcr.io/toeverything/affine-graphql:stable
|
||||
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
|
||||
- NODE_ENV=production
|
||||
- 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
|
||||
1
.github/helm/affine-cloud/.gitignore
vendored
Normal file
1
.github/helm/affine-cloud/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
charts/
|
||||
23
.github/helm/affine-cloud/.helmignore
vendored
Normal file
23
.github/helm/affine-cloud/.helmignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
6
.github/helm/affine-cloud/Chart.lock
vendored
Normal file
6
.github/helm/affine-cloud/Chart.lock
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 13.2.23
|
||||
digest: sha256:5b64538509bd067bb0f67bf082847a2c5d66dc37d0b9d7948a40405d9c446400
|
||||
generated: "2023-12-05T03:04:57.997927753Z"
|
||||
12
.github/helm/affine-cloud/Chart.yaml
vendored
Normal file
12
.github/helm/affine-cloud/Chart.yaml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
apiVersion: v2
|
||||
name: affine-cloud
|
||||
description: A Helm chart for AFFiNE Cloud
|
||||
|
||||
type: application
|
||||
version: 0.6.1
|
||||
appVersion: '0.6.1'
|
||||
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 13.2.23
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
30
.github/helm/affine-cloud/readme.md
vendored
Normal file
30
.github/helm/affine-cloud/readme.md
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Helm Chart Configuration
|
||||
|
||||
The following table lists the configurable parameters of this Helm chart and their default values.
|
||||
|
||||
## AFFiNE Cloud Server parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| ------------------------------ | -------------------------------------------------- | ------------------ |
|
||||
| `affineCloud.tag` | The Docker tag of the AffineCloud image to be used | `'nightly-latest'` |
|
||||
| `affineCloud.resources.cpu` | The CPU resources allocated for AffineCloud | `'250m'` |
|
||||
| `affineCloud.resources.memory` | The memory resources allocated for AffineCloud | `'0.5Gi'` |
|
||||
| `affineCloud.signKey` | The key used to sign the JWT tokens | `'c2VjcmV0'` |
|
||||
| `affineCloud.service.type` | The type of the Kubernetes service | `'ClusterIP'` |
|
||||
| `affineCloud.service.port` | The port of the Kubernetes service | `'http'` |
|
||||
| `affineCloud.mail.account` | The email account used to send emails | `''` |
|
||||
| `affineCloud.mail.password` | The password of the email account | `''` |
|
||||
|
||||
## PostgreSQL parameters
|
||||
|
||||
| Parameter | Description | Default |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------- | ------------ |
|
||||
| `postgresql.auth.username` | Username for the PostgreSQL database | `'affine'` |
|
||||
| `postgresql.auth.password` | Password for the PostgreSQL database. Please change this for production environments. | `'password'` |
|
||||
| `postgresql.auth.database` | The name of the default database that will be created on image startup | `'affine'` |
|
||||
| `postgresql.primary.resources.limits.cpu` | The CPU resources allocated for the PostgreSQL primary node | `'500m'` |
|
||||
| `postgresql.primary.resources.limits.memory` | The memory resources allocated for the PostgreSQL primary node | `'0.5Gi'` |
|
||||
|
||||
For more postgres parameters, please refer to: https://artifacthub.io/packages/helm/bitnami/postgresql
|
||||
|
||||
Please note that for the `postgresql.auth.password`, you should provide your own password for production environments. The default value is provided only for demonstration purposes.
|
||||
51
.github/helm/affine-cloud/templates/_helper.tpl
vendored
Normal file
51
.github/helm/affine-cloud/templates/_helper.tpl
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "affine-cloud.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "affine-cloud.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "affine-cloud.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "affine-cloud.labels" -}}
|
||||
helm.sh/chart: {{ include "affine-cloud.chart" . }}
|
||||
{{ include "affine-cloud.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "affine-cloud.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "affine-cloud.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
51
.github/helm/affine-cloud/templates/deployment.yaml
vendored
Normal file
51
.github/helm/affine-cloud/templates/deployment.yaml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "{{ include "affine-cloud.fullname" . }}"
|
||||
labels:
|
||||
{{- include "affine-cloud.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "affine-cloud.selectorLabels" . | nindent 6 }}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 2
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "affine-cloud.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
containers:
|
||||
- name: affine-cloud
|
||||
image: "ghcr.io/toeverything/cloud-self-hosted:{{ .Values.affineCloud.tag | default .Chart.AppVersion }}"
|
||||
env:
|
||||
- name: PG_USER
|
||||
value: "{{ .Values.postgresql.auth.username }}"
|
||||
- name: PG_PASS
|
||||
value: "{{ .Values.postgresql.auth.password }}"
|
||||
- name: PG_DATABASE
|
||||
value: "{{ .Values.postgresql.auth.database }}"
|
||||
- name: PG_HOST
|
||||
value: "{{ .Values.postgresql.fullnameOverride | default (printf "%s-postgresql" .Release.Name) }}"
|
||||
- name: DATABASE_URL
|
||||
value: "{{ .Values.affineCloud.databaseUrl | default "postgresql://$(PG_USER):$(PG_PASS)@$(PG_HOST)/$(PG_DATABASE)" }}"
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: affine-cloud-secret
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/healthz
|
||||
port: 3000
|
||||
failureThreshold: 1
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
limits:
|
||||
cpu: "{{ .Values.affineCloud.resources.cpu }}"
|
||||
memory: "{{ .Values.affineCloud.resources.memory }}"
|
||||
9
.github/helm/affine-cloud/templates/secret.yaml
vendored
Normal file
9
.github/helm/affine-cloud/templates/secret.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: affine-cloud-secret
|
||||
type: Opaque
|
||||
data:
|
||||
SIGN_KEY: "{{ .Values.affineCloud.signKey }}"
|
||||
MAIL_ACCOUNT: "{{ .Values.affineCloud.mail.account }}"
|
||||
MAIL_PASSWORD: "{{ .Values.affineCloud.mail.password }}"
|
||||
15
.github/helm/affine-cloud/templates/services.yaml
vendored
Normal file
15
.github/helm/affine-cloud/templates/services.yaml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "{{ include "affine-cloud.fullname" . }}"
|
||||
labels:
|
||||
{{- include "affine-cloud.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: "{{ .Values.affineCloud.service.type }}"
|
||||
ports:
|
||||
- name: http
|
||||
protocol: TCP
|
||||
port: {{ .Values.affineCloud.service.port }}
|
||||
targetPort: 3000
|
||||
selector:
|
||||
{{- include "affine-cloud.selectorLabels" . | nindent 4 }}
|
||||
30
.github/helm/affine-cloud/values.yaml
vendored
Normal file
30
.github/helm/affine-cloud/values.yaml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
affineCloud:
|
||||
tag: 'canary-5e0d5e0cc65ea46f326fdde12658bfac59b38c9f-0949'
|
||||
# databaseUrl: 'postgresql://affine:password@affine-cloud-postgresql:5432/affine'
|
||||
signKey: TUFtdFdzQTJhdGJuem01TA==
|
||||
mail:
|
||||
account: ''
|
||||
password: ''
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
resources:
|
||||
cpu: '250m'
|
||||
memory: 0.5Gi
|
||||
postgresql:
|
||||
fullnameOverride: tcp-postgresql
|
||||
auth:
|
||||
# only for demo, please modify it at prod env
|
||||
username: affine
|
||||
password: password
|
||||
database: affine
|
||||
primary:
|
||||
initdb:
|
||||
scripts:
|
||||
01-init.sql: |
|
||||
CREATE DATABASE affine_binary;
|
||||
GRANT ALL PRIVILEGES ON DATABASE affine_binary TO affine;
|
||||
resources:
|
||||
limits:
|
||||
cpu: '500m'
|
||||
memory: 0.5Gi
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.12.0"
|
||||
appVersion: "0.11.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ name: graphql
|
||||
description: AFFiNE GraphQL server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.12.0"
|
||||
appVersion: "0.11.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -39,8 +39,6 @@ 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
|
||||
@@ -75,8 +73,6 @@ 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
|
||||
@@ -140,6 +136,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||
key: secretAccessKey
|
||||
- name: R2_OBJECT_STORAGE_BUCKET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||
key: bucket
|
||||
{{ end }}
|
||||
{{ if .Values.app.captcha.enabled }}
|
||||
- name: CAPTCHA_TURNSTILE_SECRET
|
||||
@@ -149,8 +150,6 @@ spec:
|
||||
key: turnstileSecret
|
||||
{{ end }}
|
||||
{{ if .Values.app.oauth.google.enabled }}
|
||||
- name: OAUTH_GOOGLE_ENABLED
|
||||
value: "true"
|
||||
- name: OAUTH_GOOGLE_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -35,23 +35,6 @@ spec:
|
||||
- name: DATABASE_URL
|
||||
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.gcloud.cloudSqlInternal }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
|
||||
{{ end }}
|
||||
{{ if .Values.app.objectStorage.r2.enabled }}
|
||||
- name: R2_OBJECT_STORAGE_ACCOUNT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||
key: accountId
|
||||
- name: R2_OBJECT_STORAGE_ACCESS_KEY_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||
key: accessKeyId
|
||||
- name: R2_OBJECT_STORAGE_SECRET_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.objectStorage.r2.secretName }}"
|
||||
key: secretAccessKey
|
||||
{{ end }}
|
||||
resources:
|
||||
requests:
|
||||
cpu: '100m'
|
||||
|
||||
@@ -8,4 +8,5 @@ data:
|
||||
accountId: {{ .Values.app.objectStorage.r2.accountId | b64enc }}
|
||||
accessKeyId: {{ .Values.app.objectStorage.r2.accessKeyId | b64enc }}
|
||||
secretAccessKey: {{ .Values.app.objectStorage.r2.secretAccessKey | b64enc }}
|
||||
bucket: {{ .Values.app.objectStorage.r2.bucket | b64enc }}
|
||||
{{- end }}
|
||||
|
||||
@@ -16,7 +16,6 @@ app:
|
||||
path: ''
|
||||
# AFFINE_SERVER_HOST
|
||||
host: '0.0.0.0'
|
||||
https: true
|
||||
doc:
|
||||
mergeInterval: "3000"
|
||||
jwt:
|
||||
@@ -35,6 +34,7 @@ app:
|
||||
accountId: ''
|
||||
accessKeyId: ''
|
||||
secretAccessKey: ''
|
||||
bucket: ''
|
||||
oauth:
|
||||
email:
|
||||
secretName: 'oauth-email'
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -3,7 +3,7 @@ name: sync
|
||||
description: AFFiNE Sync Server
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.12.0"
|
||||
appVersion: "0.11.0"
|
||||
dependencies:
|
||||
- name: gcloud-sql-proxy
|
||||
version: 0.0.0
|
||||
|
||||
@@ -36,8 +36,6 @@ spec:
|
||||
value: "{{ .Values.env }}"
|
||||
- name: NO_COLOR
|
||||
value: "1"
|
||||
- name: DEPLOYMENT_TYPE
|
||||
value: "affine"
|
||||
- name: SERVER_FLAVOR
|
||||
value: "sync"
|
||||
- name: NEXTAUTH_URL
|
||||
|
||||
60
.github/helm/deployment_guide.md
vendored
Normal file
60
.github/helm/deployment_guide.md
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
# Cluster Deployment Guide
|
||||
|
||||
This document provides a step-by-step guide for developers on how to deploy services in a Kubernetes cluster. The following content assumes that the reader already has a basic understanding of Kubernetes concepts and operations.
|
||||
|
||||
### 1. Configure Service Mesh (Optional)
|
||||
|
||||
In the Kubernetes cluster, we optionally use Service Mesh (like Istio and Anthos Service Mesh) to manage the network interactions of microservices. If Service Mesh is already deployed on your cluster or do not need to use the service network, you can skip this step. In this step, we assume that you are using Google Kubernetes Engine (GKE) and have already installed Anthos Service Mesh on your cluster, if you wish to use another Ingress Controller, please refer to the relevant documentation.
|
||||
|
||||
To configure your kubectl context to interact with your Kubernetes cluster using the gcloud tool, you need to execute the following commands:
|
||||
|
||||
```sh
|
||||
export CLUSTER_NAME=your_cluster_name
|
||||
export REGION=your_cluster_region
|
||||
export PROJECT=your_project_id
|
||||
gcloud container clusters get-credentials $CLUSTER_NAME --region $REGION --project $PROJECT
|
||||
```
|
||||
|
||||
In this command, you should replace `CLUSTER_NAME`, `REGION` and `PROJECT` with the actual name, region and project id of your Kubernetes cluster. This command retrieves the access credentials for your Kubernetes cluster and automatically configures kubectl to use these credentials.
|
||||
|
||||
Now, to inject Service Mesh for a specific Namespace, first, set the environment variable `NAMESPACE` that should correspond to your target Kubernetes Namespace. In this example, we use `prod` as the target Namespace:
|
||||
|
||||
```sh
|
||||
export NAMESPACE=prod
|
||||
```
|
||||
|
||||
Then, we label the Namespace which will enable Istio to automatically inject the sidecar container for all new Pods under this Namespace:
|
||||
|
||||
```sh
|
||||
kubectl label namespace $NAMESPACE istio-injection- istio.io/rev=asm-managed --overwrite
|
||||
```
|
||||
|
||||
Finally, we trigger the Kubernetes Deployment restart mechanism to allow existing Pods to also obtain sidecar container injection:
|
||||
|
||||
```sh
|
||||
kubectl rollout restart deployment -n $NAMESPACE
|
||||
```
|
||||
|
||||
### 2. Deploying the Application
|
||||
|
||||
Next, we will deploy our application in the Kubernetes cluster through Helm. First, set relevant environment variables:
|
||||
|
||||
```sh
|
||||
export NAMESPACE=prod
|
||||
export RELEASE=affine-cloud-prod
|
||||
export PATH=.github/helm/affine-cloud
|
||||
```
|
||||
|
||||
- `NAMESPACE` should be consistent with the first step, indicating your target Kubernetes Namespace.
|
||||
- `RELEASE` is the name of your Helm release.
|
||||
- `PATH` is the location of your Helm chart in your file system.
|
||||
|
||||
Finally, use the `helm upgrade --install` command to deploy or upgrade your application:
|
||||
|
||||
```sh
|
||||
helm upgrade --namespace $NAMESPACE --create-namespace --install $RELEASE $PATH
|
||||
```
|
||||
|
||||
This command creates (if it doesn't already exist) and deploys your Helm chart in the specified Namespace. If the release already exists, it will be upgraded.
|
||||
|
||||
The above are the complete steps for deploying an application in a Kubernetes cluster. Make sure all prerequisites are met before deploying, and also ensure that you have the correct permissions for operations in Kubernetes.
|
||||
27
.github/labeler.yml
vendored
27
.github/labeler.yml
vendored
@@ -19,11 +19,26 @@ mod:dev:
|
||||
- 'tools/cli/**/*'
|
||||
- 'packages/common/debug/**/*'
|
||||
|
||||
mod:plugin:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/plugins/**/*'
|
||||
|
||||
plugin:copilot:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/plugins/copilot/**/*'
|
||||
|
||||
mod:infra:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/common/infra/**/*'
|
||||
|
||||
mod:sdk:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/common/sdk/**/*'
|
||||
|
||||
mod:plugin-cli:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -32,12 +47,7 @@ mod:plugin-cli:
|
||||
mod:workspace:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/common/workspace/**/*'
|
||||
|
||||
mod:workspace-impl:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/frontend/workspace-impl/**/*'
|
||||
- 'packages/frontend/workspace/**/*'
|
||||
|
||||
mod:i18n:
|
||||
- changed-files:
|
||||
@@ -49,6 +59,11 @@ mod:env:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/common/env/**/*'
|
||||
|
||||
mod:hooks:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- 'packages/frontend/hooks/**/*'
|
||||
|
||||
mod:component:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
39
.github/renovate.json
vendored
39
.github/renovate.json
vendored
@@ -1,35 +1,27 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended", ":disablePeerDependencies"],
|
||||
"labels": ["dependencies"],
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/bower_components/**",
|
||||
"**/vendor/**",
|
||||
"**/examples/**",
|
||||
"**/__tests__/**",
|
||||
"**/test/**",
|
||||
"**/__fixtures__/**"
|
||||
"extends": [
|
||||
"config:base",
|
||||
"group:allNonMajor",
|
||||
":preserveSemverRanges",
|
||||
":disablePeerDependencies"
|
||||
],
|
||||
"labels": ["dependencies"],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackageNames": ["napi", "napi-build", "napi-derive"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "napi-rs"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^eslint", "^@typescript-eslint"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "linter"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@nestjs"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "nestjs"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@opentelemetry"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "opentelemetry"
|
||||
},
|
||||
{
|
||||
@@ -38,39 +30,22 @@
|
||||
"@prisma/instrumentation",
|
||||
"prisma"
|
||||
],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "prisma"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@electron-forge"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "electron-forge"
|
||||
},
|
||||
{
|
||||
"groupName": "blocksuite-canary",
|
||||
"matchPackagePatterns": ["^@blocksuite"],
|
||||
"excludePackageNames": ["@blocksuite/icons"],
|
||||
"rangeStrategy": "replace",
|
||||
"followTag": "canary"
|
||||
},
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"excludePackagePatterns": ["^@blocksuite/"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["*"],
|
||||
"rangeStrategy": "replace",
|
||||
"excludePackagePatterns": ["^@blocksuite/"]
|
||||
"followTag": "nightly"
|
||||
}
|
||||
],
|
||||
"commitMessagePrefix": "chore: ",
|
||||
"commitMessageAction": "bump up",
|
||||
"commitMessageTopic": "{{depName}} version",
|
||||
"ignoreDeps": [],
|
||||
"postUpdateOptions": ["yarnDedupeHighest"],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"extends": ["schedule:weekly"]
|
||||
|
||||
103
.github/workflows/build-test.yml
vendored
103
.github/workflows/build-test.yml
vendored
@@ -19,7 +19,6 @@ 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
|
||||
DEPLOYMENT_TYPE: affine
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -96,8 +95,6 @@ jobs:
|
||||
run: |
|
||||
git checkout .yarnrc.yml
|
||||
yarn lint:prettier
|
||||
- name: Yarn Dedupe
|
||||
run: yarn dedupe --check
|
||||
- name: Run Type Check
|
||||
run: yarn typecheck
|
||||
|
||||
@@ -111,6 +108,44 @@ jobs:
|
||||
yarn set version $(node -e "console.log(require('./package.json').packageManager.split('@')[1])")
|
||||
git diff --exit-code
|
||||
|
||||
e2e-plugin-test:
|
||||
name: E2E Plugin Test
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISTRIBUTION: browser
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
playwright-install: true
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
- name: Run playwright tests
|
||||
run: yarn e2e --forbid-only
|
||||
working-directory: tests/affine-plugin
|
||||
env:
|
||||
COVERAGE: true
|
||||
- name: Collect code coverage report
|
||||
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
|
||||
|
||||
- name: Upload e2e test coverage results
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: ./.coverage/lcov.info
|
||||
flags: e2e-plugin-test
|
||||
name: affine
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results-e2e-plugin
|
||||
path: ./test-results
|
||||
if-no-files-found: ignore
|
||||
|
||||
e2e-test:
|
||||
name: E2E Test
|
||||
runs-on: ubuntu-latest
|
||||
@@ -134,7 +169,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.shard }}
|
||||
path: ./test-results
|
||||
@@ -159,7 +194,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results-e2e-migration
|
||||
path: ./tests/affine-migration/test-results
|
||||
@@ -181,7 +216,7 @@ jobs:
|
||||
full-cache: true
|
||||
|
||||
- name: Download affine.linux-x64-gnu.node
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine.linux-x64-gnu.node
|
||||
path: ./packages/frontend/native
|
||||
@@ -219,6 +254,8 @@ jobs:
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/native
|
||||
electron-install: false
|
||||
build-infra: false
|
||||
build-plugins: false
|
||||
- name: Setup filename
|
||||
id: filename
|
||||
shell: bash
|
||||
@@ -232,7 +269,7 @@ jobs:
|
||||
package: '@affine/native'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload ${{ steps.filename.outputs.filename }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.filename.outputs.filename }}
|
||||
path: ./packages/frontend/native/${{ steps.filename.outputs.filename }}
|
||||
@@ -250,6 +287,8 @@ jobs:
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/storage
|
||||
electron-install: false
|
||||
build-infra: false
|
||||
build-plugins: false
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
@@ -257,7 +296,7 @@ jobs:
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload storage.node
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/storage/storage.node
|
||||
@@ -273,6 +312,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
build-plugins: false
|
||||
full-cache: true
|
||||
- name: Build Core
|
||||
# always skip cache because its fast, and cache configuration is always changing
|
||||
@@ -280,7 +320,7 @@ jobs:
|
||||
- name: zip core
|
||||
run: tar -czf dist.tar.gz --directory=packages/frontend/core/dist .
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: dist.tar.gz
|
||||
@@ -291,7 +331,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-storage
|
||||
env:
|
||||
NODE_ENV: test
|
||||
DISTRIBUTION: browser
|
||||
services:
|
||||
postgres:
|
||||
@@ -319,12 +358,6 @@ jobs:
|
||||
electron-install: false
|
||||
full-cache: true
|
||||
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
|
||||
- name: Initialize database
|
||||
run: |
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE affine;"
|
||||
@@ -347,6 +380,12 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
|
||||
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
|
||||
- name: Run server tests
|
||||
run: yarn workspace @affine/server test:coverage
|
||||
env:
|
||||
@@ -411,18 +450,6 @@ jobs:
|
||||
playwright-install: true
|
||||
hard-link-nm: false
|
||||
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
|
||||
- name: Download affine.linux-x64-gnu.node
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: affine.linux-x64-gnu.node
|
||||
path: ./packages/frontend/native
|
||||
|
||||
- name: Initialize database
|
||||
run: |
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE affine;"
|
||||
@@ -442,6 +469,17 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @affine/server data-migration run
|
||||
yarn workspace @affine/server exec node --loader ts-node/esm/transpile-only ./scripts/init-db.ts
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
|
||||
- name: Download affine.linux-x64-gnu.node
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine.linux-x64-gnu.node
|
||||
path: ./packages/frontend/native
|
||||
|
||||
- name: ${{ matrix.tests.name }}
|
||||
run: |
|
||||
@@ -452,7 +490,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results-e2e-server
|
||||
path: ./tests/affine-cloud/test-results
|
||||
@@ -516,7 +554,7 @@ jobs:
|
||||
echo "filename=affine.$PLATFORM_ARCH_ABI.node" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download ${{ steps.filename.outputs.filename }}
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ${{ steps.filename.outputs.filename }}
|
||||
path: ./packages/frontend/native
|
||||
@@ -524,7 +562,8 @@ jobs:
|
||||
- name: Run unit tests
|
||||
if: ${{ matrix.spec.test }}
|
||||
shell: bash
|
||||
run: yarn workspace @affine/electron vitest
|
||||
run: yarn vitest
|
||||
working-directory: packages/frontend/electron
|
||||
|
||||
- name: Download core artifact
|
||||
uses: ./.github/actions/download-core
|
||||
@@ -557,7 +596,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: test-results-e2e-${{ matrix.spec.os }}-${{ matrix.spec.arch }}
|
||||
path: ./test-results
|
||||
|
||||
27
.github/workflows/deploy-automatically.yml
vendored
27
.github/workflows/deploy-automatically.yml
vendored
@@ -1,27 +0,0 @@
|
||||
name: Deploy Automatically
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
jobs:
|
||||
dispatch-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Setup Deploy
|
||||
steps:
|
||||
- name: dispatch deploy by tag
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: deploy.yml
|
||||
inputs: '{ "flavor": "canary" }'
|
||||
- name: dispatch deploy by schedule
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: deploy.yml
|
||||
inputs: '{ "flavor": "canary" }'
|
||||
ref: canary
|
||||
119
.github/workflows/deploy.yml
vendored
119
.github/workflows/deploy.yml
vendored
@@ -23,17 +23,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/server
|
||||
- name: Build Server
|
||||
run: yarn workspace @affine/server build
|
||||
- name: Upload server dist
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
@@ -45,10 +43,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
env:
|
||||
@@ -63,73 +62,56 @@ 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
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-core-selfhost:
|
||||
name: Build @affine/core
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: false
|
||||
PUBLIC_PATH: '/'
|
||||
- name: Download selfhost fonts
|
||||
run: node ./scripts/download-blocksuite-fonts.mjs
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: selfhost-core
|
||||
path: ./packages/frontend/core/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage:
|
||||
name: Build Storage - ${{ matrix.targets.name }}
|
||||
name: Build Storage
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
targets:
|
||||
- name: x86_64-unknown-linux-gnu
|
||||
file: storage.node
|
||||
- name: aarch64-unknown-linux-gnu
|
||||
file: storage.arm64.node
|
||||
- name: armv7-unknown-linux-gnueabihf
|
||||
file: storage.armv7.node
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
extra-flags: workspaces focus @affine/storage
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: ${{ matrix.targets.name }}
|
||||
target: 'x86_64-unknown-linux-gnu'
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload ${{ matrix.targets.file }}
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Upload storage.node
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.targets.file }}
|
||||
name: storage.node
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage-arm64:
|
||||
name: Build Storage arm64
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'aarch64-unknown-linux-gnu'
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload storage.node
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storage.arm64.node
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -139,39 +121,32 @@ jobs:
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
- build-core-selfhost
|
||||
- build-storage
|
||||
- build-storage-arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download core artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
- name: Download server dist
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storage.arm64.node
|
||||
path: ./packages/backend/storage
|
||||
- name: Download storage.node arm64
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storage.armv7.node
|
||||
path: .
|
||||
- name: move storage files
|
||||
run: |
|
||||
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
|
||||
mv storage.node ./packages/backend/server/storage.armv7.node
|
||||
- name: move storage.arm64.node
|
||||
run: mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
|
||||
- name: Setup env
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
@@ -213,19 +188,9 @@ 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", "arm"]'
|
||||
yarn config set --json supportedArchitectures.libc '["glibc"]'
|
||||
yarn config set --json supportedArchitectures.cpu '["x64", "arm64"]'
|
||||
yarn workspaces focus @affine/server --production
|
||||
|
||||
- name: Generate Prisma client
|
||||
@@ -237,7 +202,7 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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}}
|
||||
@@ -255,7 +220,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Deploy to ${{ github.event.inputs.flavor }}
|
||||
uses: ./.github/actions/deploy
|
||||
@@ -273,6 +237,7 @@ jobs:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
ENABLE_CAPTCHA: true
|
||||
CAPTCHA_TURNSTILE_SECRET: ${{ secrets.CAPTCHA_TURNSTILE_SECRET }}
|
||||
OAUTH_EMAIL_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
|
||||
|
||||
30
.github/workflows/dispatch-deploy.yml
vendored
Normal file
30
.github/workflows/dispatch-deploy.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Dispatch Deploy by tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+'
|
||||
|
||||
jobs:
|
||||
dispatch-deploy-by-tag:
|
||||
runs-on: ubuntu-latest
|
||||
name: Setup deploy environment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: 'workspaces focus @affine/monorepo'
|
||||
hard-link-nm: false
|
||||
electron-install: false
|
||||
build-infra: false
|
||||
build-plugins: false
|
||||
- name: Setup output value
|
||||
id: flavor
|
||||
run: |
|
||||
node -e "const env = require('semver').parse('${{ github.ref_name }}').prerelease[0] ?? 'stable'; console.log(`flavor=${env}`)" >> "$GITHUB_OUTPUT"
|
||||
- name: dispatch deploy
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: deploy.yml
|
||||
inputs: '{ "flavor": "${{ steps.flavor.outputs.flavor }}" }'
|
||||
258
.github/workflows/nightly-build.yml
vendored
Normal file
258
.github/workflows/nightly-build.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
||||
name: Build Canary Desktop App on Staging Branch
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel_override:
|
||||
description: 'channel type (canary, beta, or stable)'
|
||||
type: choice
|
||||
default: beta
|
||||
options:
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
push:
|
||||
branches:
|
||||
# 0.6.x-staging
|
||||
- v[0-9]+.[0-9]+.x-staging
|
||||
# 0.6.1-staging
|
||||
- v[0-9]+.[0-9]+.[0-9]+-staging
|
||||
paths-ignore:
|
||||
- README.md
|
||||
- .github/**
|
||||
- '!.github/workflows/nightly-build.yml'
|
||||
- '!.github/actions/build-rust/action.yml'
|
||||
- '!.github/actions/setup-node/action.yml'
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
security-events: write
|
||||
|
||||
concurrency:
|
||||
# The concurrency group contains the workflow name and the branch name for
|
||||
# pull requests or the commit hash for any other events.
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# BUILD_TYPE => app icon, app name, etc
|
||||
BUILD_TYPE: internal
|
||||
# BUILD_TYPE_OVERRIDE => channel type (canary, beta, or stable) - get the channel type (the api configs)
|
||||
BUILD_TYPE_OVERRIDE: ${{ github.event.inputs.channel_override || 'beta' }}
|
||||
|
||||
jobs:
|
||||
set-build-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: 0.0.0-internal.${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: toeverything/set-build-version@latest
|
||||
- id: version
|
||||
run: echo ::set-output name=version::${{ env.BUILD_VERSION }}
|
||||
|
||||
before-make:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- set-build-version
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup @sentry/cli
|
||||
uses: ./.github/actions/setup-sentry
|
||||
- name: Replace Version
|
||||
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
|
||||
- name: generate-assets
|
||||
run: yarn workspace @affine/electron generate-assets
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
|
||||
SKIP_PLUGIN_BUILD: 'true'
|
||||
SKIP_NX_CACHE: 'true'
|
||||
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
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
|
||||
platform: darwin
|
||||
arch: x64
|
||||
target: x86_64-apple-darwin
|
||||
- runner: macos-latest
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
- runner: ubuntu-latest
|
||||
platform: linux
|
||||
arch: x64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- runner: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
runs-on: ${{ matrix.spec.runner }}
|
||||
needs:
|
||||
- before-make
|
||||
- set-build-version
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
timeout-minutes: 10
|
||||
if: ${{ matrix.spec.platform == 'darwin' }}
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
hard-link-nm: false
|
||||
build-plugins: false
|
||||
nmHoistingLimits: workspaces
|
||||
enableScripts: false
|
||||
- name: Setup Node.js
|
||||
timeout-minutes: 10
|
||||
if: ${{ matrix.spec.platform != 'darwin' }}
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
hard-link-nm: false
|
||||
build-plugins: false
|
||||
nmHoistingLimits: workspaces
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: ${{ matrix.spec.target }}
|
||||
package: '@affine/native'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Replace Version
|
||||
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
|
||||
- name: Build Desktop Layers
|
||||
run: yarn workspace @affine/electron build
|
||||
|
||||
- name: Signing By Apple Developer ID
|
||||
if: ${{ matrix.spec.platform == 'darwin' }}
|
||||
uses: apple-actions/import-codesign-certs@v2
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
||||
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
||||
|
||||
- name: make
|
||||
run: yarn workspace @affine/electron make --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
|
||||
env:
|
||||
SKIP_PLUGIN_BUILD: 1
|
||||
SKIP_WEB_BUILD: 1
|
||||
HOIST_NODE_MODULES: 1
|
||||
|
||||
- name: Save artifacts (mac)
|
||||
if: ${{ matrix.spec.platform == 'darwin' }}
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/*.dmg ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.dmg
|
||||
mv packages/frontend/electron/out/*/make/zip/darwin/${{ matrix.spec.arch }}/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-macos-${{ matrix.spec.arch }}.zip
|
||||
- name: Save artifacts (windows)
|
||||
if: ${{ matrix.spec.platform == 'win32' }}
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.exe
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.msi
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.nupkg ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.nupkg
|
||||
|
||||
- name: Save artifacts (linux)
|
||||
if: ${{ matrix.spec.platform == 'linux' }}
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/AppImage/x64/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
path: builds
|
||||
|
||||
release:
|
||||
needs:
|
||||
- make-distribution
|
||||
- set-build-version
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download Artifacts (macos-x64)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-darwin-x64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (macos-arm64)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-darwin-arm64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (windows-x64)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-win32-x64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (linux-x64)
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-linux-x64-builds
|
||||
path: ./
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Generate Release yml
|
||||
run: |
|
||||
node ./packages/frontend/electron/scripts/generate-yml.js
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
|
||||
- name: Generate SHA512 checksums
|
||||
run: |
|
||||
sha512sum *-linux-* > SHA512SUMS.txt
|
||||
sha512sum *-macos-* >> SHA512SUMS.txt
|
||||
sha512sum *-windows-* >> SHA512SUMS.txt
|
||||
- name: Create Release Draft
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
repository: 'toeverything/AFFiNE-Releases'
|
||||
name: ${{ needs.set-build-version.outputs.version }}
|
||||
tag_name: ${{ needs.set-build-version.outputs.version }}
|
||||
prerelease: true
|
||||
files: |
|
||||
./SHA512SUMS.txt
|
||||
./VERSION
|
||||
./*.zip
|
||||
./*.dmg
|
||||
./*.exe
|
||||
./*.nupkg
|
||||
./RELEASES
|
||||
./*.AppImage
|
||||
./*.apk
|
||||
./*.yml
|
||||
4
.github/workflows/publish-storybook.yml
vendored
4
.github/workflows/publish-storybook.yml
vendored
@@ -32,6 +32,8 @@ jobs:
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
- uses: chromaui/action-next@v1
|
||||
with:
|
||||
workingDir: tests/storybook
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
env:
|
||||
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
NODE_OPTIONS: ${{ env.NODE_OPTIONS }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: chromatic-build-artifacts-${{ github.run_id }}
|
||||
|
||||
51
.github/workflows/publish-ui-storybook.yml
vendored
51
.github/workflows/publish-ui-storybook.yml
vendored
@@ -1,51 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +1,15 @@
|
||||
name: Release Desktop App
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build-type:
|
||||
description: 'Build Type'
|
||||
type: choice
|
||||
version:
|
||||
description: App Version
|
||||
required: true
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- beta
|
||||
- stable
|
||||
default: 0.0.0
|
||||
is-draft:
|
||||
description: 'Draft Release?'
|
||||
type: boolean
|
||||
@@ -22,6 +20,11 @@ on:
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
build-type:
|
||||
description: 'Build Type (canary, beta or stable)'
|
||||
type: string
|
||||
required: true
|
||||
default: canary
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
@@ -29,7 +32,7 @@ permissions:
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
BUILD_TYPE: ${{ github.event.inputs.build-type }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.build-type || (github.ref_type == 'tag' && contains(github.ref, 'canary') && 'canary') }}
|
||||
DEBUG: napi:*
|
||||
APP_NAME: affine
|
||||
MACOSX_DEPLOYMENT_TARGET: '10.13'
|
||||
@@ -37,18 +40,26 @@ env:
|
||||
jobs:
|
||||
before-make:
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.build-type }}
|
||||
environment: ${{ github.event.inputs.build-type || (github.ref_type == 'tag' && contains(github.ref, 'canary') && 'canary') }}
|
||||
outputs:
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.APP_VERSION }}
|
||||
RELEASE_VERSION: ${{ steps.get-canary-version.outputs.RELEASE_VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup @sentry/cli
|
||||
uses: ./.github/actions/setup-sentry
|
||||
- name: Get canary version
|
||||
id: get-canary-version
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
run: |
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
PACKAGE_VERSION=$(node -p "require('./packages/frontend/electron/package.json').version")
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo "Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)"
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_VERSION=$(node -p "require('./packages/frontend/electron/package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: generate-assets
|
||||
run: yarn workspace @affine/electron generate-assets
|
||||
env:
|
||||
@@ -56,12 +67,12 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.APP_VERSION }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.version || steps.get-canary-version.outputs.RELEASE_VERSION }}
|
||||
SKIP_PLUGIN_BUILD: 'true'
|
||||
SKIP_NX_CACHE: 'true'
|
||||
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
@@ -93,15 +104,13 @@ jobs:
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
hard-link-nm: false
|
||||
build-plugins: false
|
||||
nmHoistingLimits: workspaces
|
||||
enableScripts: false
|
||||
- name: Build AFFiNE native
|
||||
@@ -110,7 +119,7 @@ jobs:
|
||||
target: ${{ matrix.spec.target }}
|
||||
package: '@affine/native'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
@@ -143,10 +152,10 @@ jobs:
|
||||
run: |
|
||||
mkdir -p builds
|
||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||
mv packages/frontend/electron/out/*/make/AppImage/x64/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
path: builds
|
||||
@@ -169,15 +178,13 @@ jobs:
|
||||
SKIP_GENERATE_ASSETS: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Version
|
||||
id: version
|
||||
uses: ./.github/actions/setup-version
|
||||
- name: Setup Node.js
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
extra-flags: workspaces focus @affine/electron @affine/monorepo
|
||||
hard-link-nm: false
|
||||
build-plugins: false
|
||||
nmHoistingLimits: workspaces
|
||||
- name: Build AFFiNE native
|
||||
uses: ./.github/actions/build-rust
|
||||
@@ -185,11 +192,14 @@ jobs:
|
||||
target: ${{ matrix.spec.target }}
|
||||
package: '@affine/native'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: packages/frontend/electron/resources/web-static
|
||||
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
|
||||
- name: Build Desktop Layers
|
||||
run: yarn workspace @affine/electron build
|
||||
|
||||
@@ -211,7 +221,7 @@ jobs:
|
||||
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/* -DestinationPath archive.zip
|
||||
|
||||
- name: Save packaged artifacts for signing
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: |
|
||||
@@ -245,7 +255,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Download and overwrite packaged artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: .
|
||||
@@ -266,7 +276,7 @@ jobs:
|
||||
echo $FILES_TO_BE_SIGNED
|
||||
|
||||
- name: Save installer for signing
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: archive.zip
|
||||
@@ -292,7 +302,7 @@ jobs:
|
||||
runs-on: ${{ matrix.spec.runner }}
|
||||
steps:
|
||||
- name: Download and overwrite installer artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
|
||||
path: .
|
||||
@@ -307,7 +317,7 @@ jobs:
|
||||
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ env.BUILD_TYPE }}-windows-x64.msi
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
|
||||
path: builds
|
||||
@@ -318,29 +328,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: web-static
|
||||
- name: Zip web-static
|
||||
run: zip -r web-static.zip web-static
|
||||
- name: Download Artifacts (macos-x64)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-darwin-x64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (macos-arm64)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-darwin-arm64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (windows-x64)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-win32-x64-builds
|
||||
path: ./
|
||||
- name: Download Artifacts (linux-x64)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: affine-linux-x64-builds
|
||||
path: ./
|
||||
@@ -351,37 +361,14 @@ jobs:
|
||||
run: |
|
||||
node ./packages/frontend/electron/scripts/generate-yml.js
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.version || needs.before-make.outputs.RELEASE_VERSION }}
|
||||
- name: Create Release Draft
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
name: ${{ github.event.inputs.version || needs.before-make.outputs.RELEASE_VERSION }}
|
||||
body: ''
|
||||
draft: ${{ github.event.inputs.is-draft }}
|
||||
prerelease: ${{ github.event.inputs.is-pre-release }}
|
||||
files: |
|
||||
./VERSION
|
||||
./*.zip
|
||||
./*.dmg
|
||||
./*.exe
|
||||
./*.AppImage
|
||||
./*.apk
|
||||
./*.yml
|
||||
- name: Create Nightly Release Draft
|
||||
if: ${{ github.ref_type == 'branch' }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
with:
|
||||
# Temporarily, treat release from branch as nightly release, artifact saved to AFFiNE-Releases.
|
||||
# Need to improve internal build and nightly release logic.
|
||||
repository: 'toeverything/AFFiNE-Releases'
|
||||
name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
tag_name: ${{ needs.before-make.outputs.RELEASE_VERSION }}
|
||||
body: ''
|
||||
draft: false
|
||||
prerelease: true
|
||||
draft: ${{ github.event.inputs.is-draft || true }}
|
||||
prerelease: ${{ github.event.inputs.is-pre-release || needs.before-make.outputs.version }}
|
||||
files: |
|
||||
./VERSION
|
||||
./*.zip
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Release Desktop Automatically
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-canary.[0-9]+'
|
||||
schedule:
|
||||
- cron: '0 9 * * *'
|
||||
|
||||
jobs:
|
||||
dispatch-release-desktop:
|
||||
runs-on: ubuntu-latest
|
||||
name: Setup Release Desktop
|
||||
steps:
|
||||
- name: dispatch desktop release by tag
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: release-desktop.yml
|
||||
inputs: '{ "build-type": "canary", "is-draft": false, "is-pre-release": true }'
|
||||
- name: dispatch desktop release by schedule
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: release-desktop.yml
|
||||
inputs: '{ "build-type": "canary", "is-draft": false, "is-pre-release": true }'
|
||||
ref: canary
|
||||
4
.github/workflows/windows-signer.yml
vendored
4
.github/workflows/windows-signer.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
env:
|
||||
ARCHIVE_DIR: ${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.artifact-name }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ env.ARCHIVE_DIR }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
cd ${{ env.ARCHIVE_DIR }}
|
||||
7za a signed.zip .\out\*
|
||||
- name: upload
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: signed-${{ inputs.artifact-name }}
|
||||
path: ${{ env.ARCHIVE_DIR }}/signed.zip
|
||||
|
||||
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.3.2
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,6 +79,3 @@ lib
|
||||
affine.db
|
||||
apps/web/next-routes.conf
|
||||
.nx
|
||||
|
||||
packages/frontend/templates/edgeless
|
||||
packages/frontend/core/public/static/templates
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged && yarn lint:ox
|
||||
|
||||
@@ -16,9 +16,6 @@ packages/frontend/i18n/src/i18n-generated.ts
|
||||
packages/frontend/graphql/src/graphql/index.ts
|
||||
tests/affine-legacy/**/static
|
||||
.yarnrc.yml
|
||||
packages/frontend/templates/edgeless-templates.gen.ts
|
||||
packages/frontend/templates/templates.gen.ts
|
||||
packages/frontend/templates/onboarding
|
||||
|
||||
# auto-generated by NAPI-RS
|
||||
# fixme(@joooye34): need script to check and generate ignore list here
|
||||
|
||||
File diff suppressed because one or more lines are too long
1012
Cargo.lock
generated
1012
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
35
README.md
35
README.md
@@ -113,6 +113,21 @@ If you have questions, you are welcome to contact us. One of the best places to
|
||||
| [@toeverything/y-indexeddb](packages/common/y-indexeddb) | IndexedDB database adapter for Yjs | [](https://www.npmjs.com/package/@toeverything/y-indexeddb) |
|
||||
| [@toeverything/theme](packages/common/theme) | AFFiNE theme | [](https://www.npmjs.com/package/@toeverything/theme) |
|
||||
|
||||
## Plugins
|
||||
|
||||
> Plugins are a way to extend the functionality of AFFiNE. You can use plugins to add new blocks, new features, and even new ways to edit content.
|
||||
>
|
||||
> (Currently, the plugin system is under heavy development. You will see the plugin system in the canary release.)
|
||||
|
||||
- [@affine/sdk](./packages/common/sdk) - SDK for developing plugins
|
||||
- [@affine/plugin-cli](./tools/plugin-cli) - CLI for developing plugins
|
||||
|
||||
| Official Plugin | Description | Status |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------- | ------ |
|
||||
| [@affine/copilot-plugin](./packages/plugins/copilot) | AI Copilot that help you document writing | 🚧 |
|
||||
| [@affine/image-preview-plugin](./packages/plugins/image-preview) | Component for previewing an image | ✅ |
|
||||
| [@affine/outline](./packages/plugins/outline) | Outline for your document | ✅ |
|
||||
|
||||
## Upstreams
|
||||
|
||||
We would also like to give thanks to open-source projects that make AFFiNE possible:
|
||||
@@ -138,6 +153,24 @@ 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.
|
||||
@@ -194,7 +227,7 @@ See [LICENSE] for details.
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.75.0-dea584
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.74.1-dea584
|
||||
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
|
||||
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
|
||||
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
|
||||
|
||||
@@ -57,29 +57,6 @@ corepack prepare yarn@stable --activate
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Clone repository
|
||||
|
||||
#### Linux & MacOS
|
||||
|
||||
```sh
|
||||
git clone https://github.com/toeverything/AFFiNE
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
In our codebase, we use symbolic links. Due to the security design of Windows, the creation of symbolic links requires administrator privileges. This is part of the security policy settings of Windows, and more information can be found at [Security Policy Settings for Creating Symbolic Links](https://learn.microsoft.com/en-us/windows/security/threat-protection/security-policy-settings/create-symbolic-links).
|
||||
|
||||
For detailed guidance on enabling this feature, please refer to the official documentation: [Enable Developer Mode on Windows](https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development).
|
||||
|
||||
Once Developer Mode is enabled, execute the following command with administrator privileges:
|
||||
|
||||
```sh
|
||||
# Enable symbolic links
|
||||
git config --global core.symlinks true
|
||||
# Clone the repository, also need to be run with administrator privileges
|
||||
git clone https://github.com/toeverything/AFFiNE
|
||||
```
|
||||
|
||||
### Build Native Dependencies
|
||||
|
||||
Run the following script. It will build the native module at [`/packages/frontend/native`](/packages/frontend/native) and build Node.js binding using [NAPI.rs](https://napi.rs/).
|
||||
@@ -90,6 +67,18 @@ Note: use `strip` from system instead of `binutils` if you are running MacOS. [s
|
||||
yarn workspace @affine/native build
|
||||
```
|
||||
|
||||
### Build Infra
|
||||
|
||||
```sh
|
||||
yarn run build:infra
|
||||
```
|
||||
|
||||
### Build Plugins
|
||||
|
||||
```sh
|
||||
yarn run build:plugins
|
||||
```
|
||||
|
||||
### Build Server Dependencies
|
||||
|
||||
```sh
|
||||
@@ -113,7 +102,7 @@ yarn test
|
||||
### E2E Test
|
||||
|
||||
```shell
|
||||
# there are `affine-local`, `affine-migration`, `affine-local`, `affine-prototype` e2e tests,
|
||||
# there are `affine-local`, `affine-migration`, `affine-local`, `affine-plugin`, `affine-prototype` e2e tests,
|
||||
# which are run under different situations.
|
||||
cd tests/affine-local
|
||||
yarn e2e
|
||||
|
||||
@@ -17,6 +17,7 @@ The codebase is organized as follows:
|
||||
- `packages/` contains all code running in production.
|
||||
- `backend/` contains backend code, more information from <https://github.com/toeverything/OctoBase>.
|
||||
- `frontend/` contains frontend code, including the web app, the electron app and business libraries.
|
||||
- `plugins/` contains all build-in plugins.
|
||||
- `common` contains the isomorphic code or basic libraries without business.
|
||||
- `tools/` contains tools to help developing or CI, not used in production.
|
||||
- `tests/` contains testings across different libraries, including e2e testings and integration testings.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@affine/docs",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "typedoc --options ../../typedoc.json",
|
||||
"dev": "nodemon --exec 'typedoc --options ../../typedoc.json' & serve dist/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"serve": "^14.2.1",
|
||||
"typedoc": "^0.25.4"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": [
|
||||
"./readme.md",
|
||||
"../../packages/*/*/src/*.ts",
|
||||
"../../**/typedoc{.base,}.json"
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.12.0"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
Welcome to AFFiNE development reference.
|
||||
|
||||
This document is intended for developers who want to contribute to AFFiNE. It contains information about the architecture of AFFiNE, how to build it, and how to contribute to it.
|
||||
|
||||
### The Infrastructure of AFFiNE
|
||||
|
||||
see {@link @toeverything/infra!}
|
||||
3
nx.json
3
nx.json
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"npmScope": "toeverything",
|
||||
"nxCloudAccessToken": "MzUwNTU4YWItZGFhYi00YjE2LWIxODAtODk4NmIwYjMwYzZkfHJlYWQ=",
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "nx-cloud",
|
||||
"options": {
|
||||
"cacheableOperations": ["build", "test", "e2e", "lint"],
|
||||
"runtimeCacheInputs": ["node -v"]
|
||||
"accessToken": "YmQ2NTg1ODktZTk5Mi00YzhiLTk2ZmUtNWQzMDg0NDBkOWM3fHJlYWQtb25seQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
42
package.json
42
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.12.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -8,9 +8,10 @@
|
||||
".",
|
||||
"packages/*/*",
|
||||
"tools/*",
|
||||
"docs/reference",
|
||||
"!tools/@types",
|
||||
"tools/@types/*",
|
||||
"tests/*",
|
||||
"!tests/affine-legacy",
|
||||
"tests/affine-legacy/*"
|
||||
],
|
||||
"engines": {
|
||||
@@ -22,6 +23,8 @@
|
||||
"build": "yarn nx build @affine/core",
|
||||
"build:electron": "yarn nx build @affine/electron",
|
||||
"build:storage": "yarn nx run-many -t build -p @affine/storage",
|
||||
"build:infra": "yarn nx run-many -t build --projects=tag:infra",
|
||||
"build:plugins": "yarn nx run-many -t build --projects=tag:plugin",
|
||||
"build:storybook": "yarn nx build @affine/storybook",
|
||||
"start:web-static": "yarn workspace @affine/core static-server",
|
||||
"start:storybook": "yarn exec serve tests/storybook/storybook-static -l 6006",
|
||||
@@ -30,15 +33,14 @@
|
||||
"lint:eslint:fix": "yarn lint:eslint --fix",
|
||||
"lint:prettier": "prettier --ignore-unknown --cache --check .",
|
||||
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
|
||||
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export",
|
||||
"lint:ox": "oxlint --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment",
|
||||
"lint": "yarn lint:eslint && yarn lint:prettier",
|
||||
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
|
||||
"test": "vitest --run",
|
||||
"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",
|
||||
"prepare": "husky"
|
||||
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown --cache",
|
||||
@@ -56,13 +58,15 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@affine/cli": "workspace:*",
|
||||
"@affine/plugin-cli": "workspace:*",
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@faker-js/faker": "^8.3.1",
|
||||
"@istanbuljs/schema": "^0.1.3",
|
||||
"@magic-works/i18n-codegen": "^0.5.0",
|
||||
"@nx/vite": "17.2.8",
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@nx/vite": "17.1.3",
|
||||
"@perfsee/sdk": "^1.9.0",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@taplo/cli": "^0.5.2",
|
||||
"@testing-library/react": "^14.1.2",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
@@ -74,9 +78,9 @@
|
||||
"@vanilla-extract/vite-plugin": "^3.9.2",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@vitest/coverage-istanbul": "1.1.3",
|
||||
"@vitest/ui": "1.1.3",
|
||||
"electron": "^28.2.1",
|
||||
"@vitest/coverage-istanbul": "1.0.4",
|
||||
"@vitest/ui": "1.0.4",
|
||||
"electron": "^27.1.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-i": "^2.29.0",
|
||||
@@ -84,18 +88,19 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"eslint-plugin-sonarjs": "^0.23.0",
|
||||
"eslint-plugin-unicorn": "^50.0.0",
|
||||
"eslint-plugin-unicorn": "^49.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": "^9.0.6",
|
||||
"fake-indexeddb": "5.0.1",
|
||||
"happy-dom": "^12.10.3",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
"msw": "^2.0.8",
|
||||
"nanoid": "^5.0.3",
|
||||
"nx": "^17.2.8",
|
||||
"nx": "^17.1.3",
|
||||
"nx-cloud": "^16.5.2",
|
||||
"nyc": "^15.1.0",
|
||||
"oxlint": "0.0.22",
|
||||
"oxlint": "0.0.21",
|
||||
"prettier": "^3.1.0",
|
||||
"semver": "^7.5.4",
|
||||
"serve": "^14.2.1",
|
||||
@@ -106,7 +111,7 @@
|
||||
"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.0.4",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
@@ -171,7 +176,6 @@
|
||||
"next-auth@^4.24.5": "patch:next-auth@npm%3A4.24.5#~/.yarn/patches/next-auth-npm-4.24.5-8428e11927.patch",
|
||||
"@reforged/maker-appimage/@electron-forge/maker-base": "7.2.0",
|
||||
"macos-alias": "npm:macos-alias-building@latest",
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest",
|
||||
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
|
||||
"fs-xattr": "npm:@napi-rs/xattr@latest"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# AFFINE_SERVER_PORT=3010
|
||||
# AFFINE_SERVER_HOST=app.affine.pro
|
||||
# AFFINE_SERVER_HTTPS=true
|
||||
# DATABASE_URL="postgres://affine@localhost:5432/affine"
|
||||
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=
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "workspace_features" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"workspace_id" VARCHAR(36) NOT NULL,
|
||||
"feature_id" INTEGER NOT NULL,
|
||||
"reason" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expired_at" TIMESTAMPTZ(6),
|
||||
"activated" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "workspace_features_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_features" ADD CONSTRAINT "workspace_features_feature_id_fkey" FOREIGN KEY ("feature_id") REFERENCES "features"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "workspace_features" ADD CONSTRAINT "workspace_features_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.11.0",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -9,21 +9,20 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node --loader ts-node/esm/transpile-only.mjs --es-module-specifier-resolution node ./src/index.ts",
|
||||
"start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts",
|
||||
"dev": "nodemon ./src/index.ts",
|
||||
"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/index.ts",
|
||||
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/index.js run"
|
||||
"data-migration": "node --loader ts-node/esm.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.9.5",
|
||||
"@auth/prisma-adapter": "^1.0.7",
|
||||
"@aws-sdk/client-s3": "^3.499.0",
|
||||
"@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",
|
||||
@@ -33,39 +32,35 @@
|
||||
"@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",
|
||||
"@node-rs/jsonwebtoken": "^0.2.3",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/core": "^1.21.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.21.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.21.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.21.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.21.0",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/instrumentation": "^5.7.1",
|
||||
"@opentelemetry/core": "^1.18.1",
|
||||
"@opentelemetry/exporter-prometheus": "^0.45.1",
|
||||
"@opentelemetry/exporter-zipkin": "^1.18.1",
|
||||
"@opentelemetry/host-metrics": "^0.34.0",
|
||||
"@opentelemetry/instrumentation": "^0.45.1",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.45.1",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.34.3",
|
||||
"@opentelemetry/resources": "^1.18.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.18.1",
|
||||
"@opentelemetry/sdk-node": "^0.45.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.18.1",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@prisma/instrumentation": "^5.6.0",
|
||||
"@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",
|
||||
"file-type": "^18.7.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",
|
||||
@@ -79,9 +74,9 @@
|
||||
"on-headers": "^1.0.2",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"prisma": "^5.7.1",
|
||||
"prisma": "^5.6.0",
|
||||
"prom-client": "^15.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
@@ -106,10 +101,10 @@
|
||||
"@types/on-headers": "^1.0.3",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/sinon": "^17.0.2",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@types/supertest": "^2.0.16",
|
||||
"@types/ws": "^8.5.10",
|
||||
"ava": "^6.0.0",
|
||||
"c8": "^9.0.0",
|
||||
"c8": "^8.0.1",
|
||||
"nodemon": "^3.0.1",
|
||||
"sinon": "^17.0.1",
|
||||
"supertest": "^6.3.3",
|
||||
@@ -117,7 +112,6 @@
|
||||
"typescript": "^5.3.2"
|
||||
},
|
||||
"ava": {
|
||||
"timeout": "1m",
|
||||
"extensions": {
|
||||
"ts": "module"
|
||||
},
|
||||
@@ -162,6 +156,7 @@
|
||||
"env": {
|
||||
"TS_NODE_TRANSPILE_ONLY": true,
|
||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||
"NODE_ENV": "development",
|
||||
"DEBUG": "affine:*",
|
||||
"FORCE_COLOR": true,
|
||||
"DEBUG_COLORS": true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x", "linux-arm64-openssl-3.0.x"]
|
||||
previewFeatures = ["metrics", "tracing", "relationJoins", "nativeDistinct"]
|
||||
previewFeatures = ["metrics", "tracing"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -40,7 +40,6 @@ model Workspace {
|
||||
pages WorkspacePage[]
|
||||
permissions WorkspaceUserPermission[]
|
||||
pagePermissions WorkspacePageUserPermission[]
|
||||
features WorkspaceFeatures[]
|
||||
|
||||
@@map("workspaces")
|
||||
}
|
||||
@@ -136,39 +135,12 @@ model UserFeatures {
|
||||
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_features")
|
||||
}
|
||||
|
||||
// feature gates is a way to enable/disable features for a workspace
|
||||
// for example:
|
||||
// - copilet is a feature that allow some users in a workspace to access the copilet feature
|
||||
model WorkspaceFeatures {
|
||||
id Int @id @default(autoincrement())
|
||||
workspaceId String @map("workspace_id") @db.VarChar(36)
|
||||
featureId Int @map("feature_id") @db.Integer
|
||||
|
||||
// we will record the reason why the feature is enabled/disabled
|
||||
// for example:
|
||||
// - copilet_v1: "owner buy the copilet feature package"
|
||||
reason String @db.VarChar
|
||||
// record the feature enabled time
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// record the quota expired time, pay plan is a subscription, so it will expired
|
||||
expiredAt DateTime? @map("expired_at") @db.Timestamptz(6)
|
||||
// whether the feature is activated
|
||||
// for example:
|
||||
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
|
||||
activated Boolean @default(false)
|
||||
|
||||
feature Features @relation(fields: [featureId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("workspace_features")
|
||||
}
|
||||
|
||||
model Features {
|
||||
id Int @id @default(autoincrement())
|
||||
feature String @db.VarChar
|
||||
@@ -179,8 +151,7 @@ model Features {
|
||||
configs Json @db.Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
UserFeatureGates UserFeatures[]
|
||||
WorkspaceFeatures WorkspaceFeatures[]
|
||||
UserFeatureGates UserFeatures[]
|
||||
|
||||
@@unique([feature, version])
|
||||
@@map("features")
|
||||
@@ -225,7 +196,6 @@ model VerificationToken {
|
||||
@@map("verificationtokens")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model Blob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
@@ -240,7 +210,6 @@ model Blob {
|
||||
@@map("blobs")
|
||||
}
|
||||
|
||||
// deprecated, use [ObjectStorage]
|
||||
model OptimizedBlob {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
hash String @db.VarChar
|
||||
@@ -265,9 +234,7 @@ model Snapshot {
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_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)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@id([id, workspaceId])
|
||||
@@map("snapshots")
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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.*$)|("use\sstrict";?)|(^.*eslint-disable.*$)/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();
|
||||
@@ -1,18 +1,13 @@
|
||||
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: this.config.version,
|
||||
message: `AFFiNE ${this.config.version} Server`,
|
||||
type: this.config.type,
|
||||
flavor: this.config.flavor,
|
||||
compatibility: version,
|
||||
message: `AFFiNE ${version} Server`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
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.isSelfhosted ? [] : [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)
|
||||
|
||||
// graphql server only
|
||||
.useIf(
|
||||
config => config.flavor.graphql,
|
||||
ServerConfigModule,
|
||||
WebSocketModule,
|
||||
GqlModule,
|
||||
StorageModule,
|
||||
UsersModule,
|
||||
WorkspaceModule,
|
||||
FeatureModule,
|
||||
QuotaModule
|
||||
)
|
||||
|
||||
// self hosted server only
|
||||
.useIf(
|
||||
config => config.isSelfhosted,
|
||||
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();
|
||||
@@ -1,48 +1,36 @@
|
||||
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 { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
|
||||
import { SocketIoAdapter } from './fundamentals';
|
||||
import { SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { ExceptionLogger } from './middleware/exception-logger';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
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 { StorageModule } from './storage';
|
||||
import { RateLimiterModule } from './throttler';
|
||||
|
||||
export async function createApp() {
|
||||
const { AppModule } = await import('./app.module');
|
||||
const BasicModules = [
|
||||
PrismaModule,
|
||||
ConfigModule.forRoot(),
|
||||
CacheModule,
|
||||
EventModule,
|
||||
StorageModule.forRoot(),
|
||||
SessionModule,
|
||||
RateLimiterModule,
|
||||
AuthModule,
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: CacheInterceptor,
|
||||
},
|
||||
],
|
||||
imports: [...BasicModules, ...BusinessModules],
|
||||
controllers: [AppController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
import Keyv from 'keyv';
|
||||
|
||||
import type { Cache, CacheSetOptions } from './def';
|
||||
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>;
|
||||
}
|
||||
|
||||
export class LocalCache implements Cache {
|
||||
private readonly kv: Keyv;
|
||||
|
||||
constructor(opts: Keyv.Options<any> = {}) {
|
||||
this.kv = new Keyv(opts);
|
||||
constructor() {
|
||||
this.kv = new Keyv();
|
||||
}
|
||||
|
||||
// standard operation
|
||||
26
packages/backend/server/src/cache/index.ts
vendored
Normal file
26
packages/backend/server/src/cache/index.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
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';
|
||||
@@ -10,7 +10,7 @@ import { Reflector } from '@nestjs/core';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { mergeMap, Observable, of } from 'rxjs';
|
||||
|
||||
import { Cache } from './instances';
|
||||
import { LocalCache } from './cache';
|
||||
|
||||
export const MakeCache = (key: string[], args?: string[]) =>
|
||||
SetMetadata('cacheKey', [key, args]);
|
||||
@@ -24,7 +24,7 @@ export class CacheInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(CacheInterceptor.name);
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly cache: Cache
|
||||
private readonly cache: LocalCache
|
||||
) {}
|
||||
async intercept(
|
||||
ctx: ExecutionContext,
|
||||
@@ -40,9 +40,9 @@ export class CacheInterceptor implements NestInterceptor {
|
||||
);
|
||||
|
||||
if (preventKey) {
|
||||
this.logger.debug(`prevent cache: ${JSON.stringify(preventKey)}`);
|
||||
const key = await this.getCacheKey(ctx, preventKey);
|
||||
if (key) {
|
||||
this.logger.debug(`cache ${key} staled`);
|
||||
await this.cache.delete(key);
|
||||
}
|
||||
|
||||
@@ -60,12 +60,12 @@ export class CacheInterceptor implements NestInterceptor {
|
||||
const cachedData = await this.cache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
this.logger.debug(`cache ${cacheKey} hit`);
|
||||
this.logger.debug('cache hit', cacheKey, cachedData);
|
||||
return of(cachedData);
|
||||
} else {
|
||||
this.logger.debug(`cache ${cacheKey} miss`);
|
||||
return next.handle().pipe(
|
||||
mergeMap(async result => {
|
||||
this.logger.debug('cache miss', cacheKey, result);
|
||||
await this.cache.set(cacheKey, result);
|
||||
|
||||
return result;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
import type { Cache, CacheSetOptions } from '../../fundamentals/cache/def';
|
||||
import { Cache, CacheSetOptions } from './cache';
|
||||
|
||||
export class RedisCache implements Cache {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Convenient way to map environment variables to config values.
|
||||
AFFiNE.ENV_MAP = {
|
||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
||||
AFFINE_SERVER_HOST: 'host',
|
||||
AFFINE_SERVER_SUB_PATH: 'path',
|
||||
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
|
||||
DATABASE_URL: 'db.url',
|
||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||
OAUTH_GOOGLE_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.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'],
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
/* 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.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'
|
||||
}`;
|
||||
}
|
||||
|
||||
AFFiNE.plugins.use('redis');
|
||||
AFFiNE.plugins.use('payment');
|
||||
|
||||
if (AFFiNE.deploy) {
|
||||
AFFiNE.plugins.use('gcloud');
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
//
|
||||
// ###############################################################
|
||||
// ## 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 */
|
||||
});
|
||||
// /* Payment Plugin */
|
||||
AFFiNE.plugins.use('payment', {
|
||||
stripe: { keys: {}, apiVersion: '2023-10-16' },
|
||||
});
|
||||
//
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
|
||||
import type { LeafPaths } from '../utils/types';
|
||||
import { EnvConfigType } from './env';
|
||||
import type { AFFiNEStorageConfig } from './storage';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -18,33 +16,54 @@ export enum ExternalAccount {
|
||||
firebase = 'firebase',
|
||||
}
|
||||
|
||||
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
|
||||
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
|
||||
export type NODE_ENV = 'development' | 'test' | 'production';
|
||||
export type ServerFlavor = 'allinone' | 'graphql' | 'sync' | 'selfhosted';
|
||||
|
||||
export enum DeploymentType {
|
||||
Affine = 'affine',
|
||||
Selfhosted = 'selfhosted',
|
||||
}
|
||||
|
||||
export type ConfigPaths = LeafPaths<
|
||||
type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
|
||||
type ConfigPaths = LeafPaths<
|
||||
Omit<
|
||||
AFFiNEConfig,
|
||||
| 'ENV_MAP'
|
||||
| 'version'
|
||||
| 'type'
|
||||
| 'isSelfhosted'
|
||||
| 'flavor'
|
||||
| 'env'
|
||||
| 'affine'
|
||||
| 'deploy'
|
||||
| 'node'
|
||||
| 'baseUrl'
|
||||
| 'origin'
|
||||
| 'prod'
|
||||
| 'dev'
|
||||
| 'test'
|
||||
| 'deploy'
|
||||
>,
|
||||
'',
|
||||
'.....'
|
||||
'....'
|
||||
>;
|
||||
/**
|
||||
* parse number value from environment variables
|
||||
*/
|
||||
function int(value: string) {
|
||||
const n = parseInt(value);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
function float(value: string) {
|
||||
const n = parseFloat(value);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
function boolean(value: string) {
|
||||
return value === '1' || value.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return type === 'int'
|
||||
? int(value)
|
||||
: type === 'float'
|
||||
? float(value)
|
||||
: type === 'boolean'
|
||||
? boolean(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
/**
|
||||
* All Configurations that would control AFFiNE server behaviors
|
||||
@@ -55,49 +74,22 @@ export interface AFFiNEConfig {
|
||||
/**
|
||||
* Server Identity
|
||||
*/
|
||||
serverId: string;
|
||||
|
||||
/**
|
||||
* Name may show on the UI
|
||||
*/
|
||||
serverName: string;
|
||||
|
||||
readonly serverId: string;
|
||||
/**
|
||||
* System version
|
||||
*/
|
||||
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;
|
||||
graphql: boolean;
|
||||
sync: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deployment environment
|
||||
*/
|
||||
readonly AFFINE_ENV: AFFINE_ENV;
|
||||
readonly affineEnv: 'dev' | 'beta' | 'production';
|
||||
/**
|
||||
* alias to `process.env.NODE_ENV`
|
||||
*
|
||||
* @default 'development'
|
||||
* @default 'production'
|
||||
* @env NODE_ENV
|
||||
*/
|
||||
readonly NODE_ENV: NODE_ENV;
|
||||
|
||||
readonly env: string;
|
||||
/**
|
||||
* fast AFFiNE environment judge
|
||||
*/
|
||||
@@ -114,7 +106,6 @@ export interface AFFiNEConfig {
|
||||
dev: boolean;
|
||||
test: boolean;
|
||||
};
|
||||
|
||||
get deploy(): boolean;
|
||||
|
||||
/**
|
||||
@@ -174,11 +165,30 @@ export interface AFFiNEConfig {
|
||||
featureFlags: {
|
||||
earlyAccessPreview: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for Object Storage, which defines how blobs and avatar assets are stored.
|
||||
* object storage Config
|
||||
*
|
||||
* all artifacts and logs will be stored on instance disk,
|
||||
* and can not shared between instances if not configured
|
||||
*/
|
||||
storage: AFFiNEStorageConfig;
|
||||
objectStorage: {
|
||||
/**
|
||||
* whether use remote object storage
|
||||
*/
|
||||
r2: {
|
||||
enabled: boolean;
|
||||
accountId: string;
|
||||
bucket: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
};
|
||||
/**
|
||||
* Only used when `enable` is `false`
|
||||
*/
|
||||
fs: {
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Rate limiter config
|
||||
@@ -198,6 +208,38 @@ export interface AFFiNEConfig {
|
||||
limit: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis Config
|
||||
*
|
||||
* whether to use redis as Socket.IO adapter
|
||||
*/
|
||||
redis: {
|
||||
/**
|
||||
* if not enabled, use in-memory adapter by default
|
||||
*/
|
||||
enabled: boolean;
|
||||
/**
|
||||
* url of redis host
|
||||
*/
|
||||
host: string;
|
||||
/**
|
||||
* port of redis
|
||||
*/
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
/**
|
||||
* redis database index
|
||||
*
|
||||
* Rate Limiter scope: database + 1
|
||||
*
|
||||
* Session scope: database + 2
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
database: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* authentication config
|
||||
*/
|
||||
@@ -316,17 +358,11 @@ export interface AFFiNEConfig {
|
||||
updatePollInterval: number;
|
||||
|
||||
/**
|
||||
* The maximum number of updates that will be pulled from the server at once.
|
||||
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
|
||||
*/
|
||||
maxUpdatesPullCount: number;
|
||||
|
||||
/**
|
||||
* Use `y-octo` to merge updates at the same time when merging using Yjs.
|
||||
* Use JwstCodec to merge updates at the same time when merging using Yjs.
|
||||
*
|
||||
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
|
||||
*/
|
||||
experimentalMergeWithYOcto: boolean;
|
||||
experimentalMergeWithJwstCodec: boolean;
|
||||
};
|
||||
history: {
|
||||
/**
|
||||
@@ -338,9 +374,12 @@ export interface AFFiNEConfig {
|
||||
};
|
||||
};
|
||||
|
||||
metrics: {
|
||||
enabled: boolean;
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: string;
|
||||
webhookKey: string;
|
||||
};
|
||||
} & import('stripe').Stripe.StripeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export * from './storage';
|
||||
231
packages/backend/server/src/config/default.ts
Normal file
231
packages/backend/server/src/config/default.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/// <reference types="../global.d.ts" />
|
||||
|
||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import parse from 'parse-duration';
|
||||
|
||||
import pkg from '../../package.json' assert { type: 'json' };
|
||||
import type { AFFiNEConfig, ServerFlavor } from './def';
|
||||
import { applyEnvToConfig } from './env';
|
||||
|
||||
export const SERVER_FLAVOR = (process.env.SERVER_FLAVOR ??
|
||||
'allinone') as ServerFlavor;
|
||||
|
||||
// Don't use this in production
|
||||
export const examplePrivateKey = `-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIEtyAJLIULkphVhqXqxk4Nr8Ggty3XLwUJWBxzAWCWTMoAoGCCqGSM49
|
||||
AwEHoUQDQgAEF3U/0wIeJ3jRKXeFKqQyBKlr9F7xaAUScRrAuSP33rajm3cdfihI
|
||||
3JvMxVNsS2lE8PSGQrvDrJZaDo0L+Lq9Gg==
|
||||
-----END EC PRIVATE KEY-----`;
|
||||
|
||||
const jwtKeyPair = (function () {
|
||||
const AUTH_PRIVATE_KEY = process.env.AUTH_PRIVATE_KEY ?? examplePrivateKey;
|
||||
const privateKey = createPrivateKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'sec1',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'pkcs8',
|
||||
})
|
||||
.toString('utf8');
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(AUTH_PRIVATE_KEY),
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.export({
|
||||
format: 'pem',
|
||||
type: 'spki',
|
||||
})
|
||||
.toString('utf8');
|
||||
|
||||
return {
|
||||
publicKey,
|
||||
privateKey,
|
||||
};
|
||||
})();
|
||||
|
||||
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
const defaultConfig = {
|
||||
serverId: 'affine-nestjs-server',
|
||||
version: pkg.version,
|
||||
ENV_MAP: {
|
||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
||||
AFFINE_SERVER_HOST: 'host',
|
||||
AFFINE_SERVER_SUB_PATH: 'path',
|
||||
AFFINE_ENV: 'affineEnv',
|
||||
DATABASE_URL: 'db.url',
|
||||
ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'],
|
||||
R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId',
|
||||
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
|
||||
R2_OBJECT_STORAGE_SECRET_ACCESS_KEY: 'objectStorage.r2.secretAccessKey',
|
||||
R2_OBJECT_STORAGE_BUCKET: 'objectStorage.r2.bucket',
|
||||
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_ENABLED: ['redis.enabled', 'boolean'],
|
||||
REDIS_SERVER_HOST: 'redis.host',
|
||||
REDIS_SERVER_PORT: ['redis.port', 'int'],
|
||||
REDIS_SERVER_USER: 'redis.username',
|
||||
REDIS_SERVER_PASSWORD: 'redis.password',
|
||||
REDIS_SERVER_DATABASE: ['redis.database', '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: 'payment.stripe.keys.APIKey',
|
||||
STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey',
|
||||
FEATURES_EARLY_ACCESS_PREVIEW: [
|
||||
'featureFlags.earlyAccessPreview',
|
||||
'boolean',
|
||||
],
|
||||
} satisfies AFFiNEConfig['ENV_MAP'],
|
||||
affineEnv: 'dev',
|
||||
get affine() {
|
||||
const env = this.affineEnv;
|
||||
return {
|
||||
canary: env === 'dev',
|
||||
beta: env === 'beta',
|
||||
stable: env === 'production',
|
||||
};
|
||||
},
|
||||
env: process.env.NODE_ENV ?? 'development',
|
||||
get node() {
|
||||
const env = this.env;
|
||||
return {
|
||||
prod: env === 'production',
|
||||
dev: env === 'development',
|
||||
test: env === 'test',
|
||||
};
|
||||
},
|
||||
get deploy() {
|
||||
return !this.node.dev && !this.node.test;
|
||||
},
|
||||
featureFlags: {
|
||||
earlyAccessPreview: false,
|
||||
},
|
||||
get https() {
|
||||
return !this.node.dev;
|
||||
},
|
||||
host: 'localhost',
|
||||
port: 3010,
|
||||
path: '',
|
||||
db: {
|
||||
url: '',
|
||||
},
|
||||
get origin() {
|
||||
return this.node.dev
|
||||
? 'http://localhost:8080'
|
||||
: `${this.https ? 'https' : 'http'}://${this.host}${
|
||||
this.host === 'localhost' ? `:${this.port}` : ''
|
||||
}`;
|
||||
},
|
||||
get baseUrl() {
|
||||
return `${this.origin}${this.path}`;
|
||||
},
|
||||
graphql: {
|
||||
buildSchemaOptions: {
|
||||
numberScalarMode: 'integer',
|
||||
},
|
||||
introspection: true,
|
||||
playground: true,
|
||||
},
|
||||
auth: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
accessTokenExpiresIn: parse('1h')! / 1000,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||
leeway: 60,
|
||||
captcha: {
|
||||
enable: false,
|
||||
turnstile: {
|
||||
secret: '1x0000000000000000000000000000000AA',
|
||||
},
|
||||
challenge: {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
privateKey: jwtKeyPair.privateKey,
|
||||
publicKey: jwtKeyPair.publicKey,
|
||||
enableSignup: true,
|
||||
enableOauth: false,
|
||||
get nextAuthSecret() {
|
||||
return this.privateKey;
|
||||
},
|
||||
oauthProviders: {},
|
||||
localEmail: false,
|
||||
email: {
|
||||
server: 'smtp.gmail.com',
|
||||
port: 465,
|
||||
login: '',
|
||||
sender: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
objectStorage: {
|
||||
r2: {
|
||||
enabled: false,
|
||||
bucket: '',
|
||||
accountId: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
},
|
||||
fs: {
|
||||
path: join(homedir(), '.affine-storage'),
|
||||
},
|
||||
},
|
||||
rateLimiter: {
|
||||
ttl: 60,
|
||||
limit: 60,
|
||||
},
|
||||
redis: {
|
||||
enabled: false,
|
||||
host: '127.0.0.1',
|
||||
port: 6379,
|
||||
username: '',
|
||||
password: '',
|
||||
database: 0,
|
||||
},
|
||||
doc: {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: SERVER_FLAVOR !== 'sync',
|
||||
updatePollInterval: 3000,
|
||||
experimentalMergeWithJwstCodec: false,
|
||||
},
|
||||
history: {
|
||||
interval: 1000 * 60 * 10 /* 10 mins */,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: '',
|
||||
webhookKey: '',
|
||||
},
|
||||
apiVersion: '2023-10-16',
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
applyEnvToConfig(defaultConfig);
|
||||
|
||||
return defaultConfig;
|
||||
};
|
||||
17
packages/backend/server/src/config/env.ts
Normal file
17
packages/backend/server/src/config/env.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { DynamicModule, FactoryProvider } from '@nestjs/common';
|
||||
// eslint-disable-next-line simple-import-sort/imports
|
||||
import type { DynamicModule, FactoryProvider } from '@nestjs/common';
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import { ApplyType } from '../utils/types';
|
||||
import { AFFiNEConfig } from './def';
|
||||
import type { DeepPartial } from '../utils/types';
|
||||
import type { AFFiNEConfig } from './def';
|
||||
|
||||
import '../prelude';
|
||||
|
||||
type ConstructorOf<T> = {
|
||||
new (): T;
|
||||
};
|
||||
|
||||
function ApplyType<T>(): ConstructorOf<T> {
|
||||
// @ts-expect-error used to fake the type of config
|
||||
return class Inner implements T {
|
||||
constructor() {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
*
|
||||
* usage:
|
||||
* ```
|
||||
* import { Config } from '@affine/server'
|
||||
*
|
||||
* class TestConfig {
|
||||
@@ -15,6 +29,7 @@ import { AFFiNEConfig } from './def';
|
||||
* return this.config.env
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class Config extends ApplyType<AFFiNEConfig>() {}
|
||||
|
||||
@@ -56,3 +71,6 @@ export class ConfigModule {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export type { AFFiNEConfig } from './def';
|
||||
export { SERVER_FLAVOR } from './default';
|
||||
3
packages/backend/server/src/constants.ts
Normal file
3
packages/backend/server/src/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const OPERATION_NAME = 'x-operation-name';
|
||||
|
||||
export const REQUEST_ID = 'x-request-id';
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DeploymentType } from '../fundamentals';
|
||||
|
||||
export enum ServerFeature {
|
||||
Payment = 'payment',
|
||||
}
|
||||
|
||||
registerEnumType(ServerFeature, {
|
||||
name: 'ServerFeature',
|
||||
});
|
||||
|
||||
registerEnumType(DeploymentType, {
|
||||
name: 'ServerDeploymentType',
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@Field(() => DeploymentType, { description: 'server type' })
|
||||
type!: DeploymentType;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
|
||||
flavor!: string;
|
||||
|
||||
@Field(() => [ServerFeature], { description: 'enabled server features' })
|
||||
features!: ServerFeature[];
|
||||
}
|
||||
export class ServerConfigResolver {
|
||||
@Query(() => ServerConfigType, {
|
||||
description: 'server config',
|
||||
})
|
||||
serverConfig(): ServerConfigType {
|
||||
return {
|
||||
name: AFFiNE.serverName,
|
||||
version: AFFiNE.version,
|
||||
baseUrl: AFFiNE.baseUrl,
|
||||
type: AFFiNE.type,
|
||||
// BACKWARD COMPATIBILITY
|
||||
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||
// this field should be removed after frontend feature flags implemented
|
||||
flavor: AFFiNE.type,
|
||||
features: ENABLED_FEATURES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
providers: [ServerConfigResolver],
|
||||
})
|
||||
export class ServerConfigModule {}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, PrismaService } from '../../fundamentals';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
const STAFF = ['@toeverything.info'];
|
||||
|
||||
@Injectable()
|
||||
export class FeatureManagementService {
|
||||
protected logger = new Logger(FeatureManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
// ======== Admin ========
|
||||
|
||||
// todo(@darkskygit): replace this with abac
|
||||
isStaff(email: string) {
|
||||
for (const domain of STAFF) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
|
||||
async addEarlyAccess(userId: string) {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
FeatureType.EarlyAccess,
|
||||
2,
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
|
||||
async removeEarlyAccess(userId: string) {
|
||||
return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
async listEarlyAccess() {
|
||||
return this.feature.listFeatureUsers(FeatureType.EarlyAccess);
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const canEarlyAccess = await this.feature
|
||||
.hasUserFeature(user.id, FeatureType.EarlyAccess)
|
||||
.catch(() => false);
|
||||
|
||||
return canEarlyAccess;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// check early access by email
|
||||
async canEarlyAccess(email: string) {
|
||||
if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) {
|
||||
return this.isEarlyAccessUser(email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======== Workspace Feature ========
|
||||
async addWorkspaceFeatures(
|
||||
workspaceId: string,
|
||||
feature: FeatureType,
|
||||
version?: number,
|
||||
reason?: string
|
||||
) {
|
||||
const latestVersions = await this.feature.getFeaturesVersion();
|
||||
// use latest version if not specified
|
||||
const latestVersion = version || latestVersions[feature];
|
||||
if (!Number.isInteger(latestVersion)) {
|
||||
throw new Error(`Version of feature ${feature} not found`);
|
||||
}
|
||||
return this.feature.addWorkspaceFeature(
|
||||
workspaceId,
|
||||
feature,
|
||||
latestVersion,
|
||||
reason || 'add feature by api'
|
||||
);
|
||||
}
|
||||
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.feature.getWorkspaceFeatures(workspaceId);
|
||||
return features.filter(f => f.activated).map(f => f.feature.name);
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.feature.hasWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.feature
|
||||
.removeWorkspaceFeature(workspaceId, feature)
|
||||
.then(c => c > 0);
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType) {
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../fundamentals';
|
||||
import { UserType } from '../users/types';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class FeatureService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getFeaturesVersion() {
|
||||
const features = await this.prisma.features.findMany({
|
||||
where: {
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
select: {
|
||||
feature: true,
|
||||
version: true,
|
||||
},
|
||||
});
|
||||
return features.reduce(
|
||||
(acc, feature) => {
|
||||
// only keep the latest version
|
||||
if (acc[feature.feature]) {
|
||||
if (acc[feature.feature] < feature.version) {
|
||||
acc[feature.feature] = feature.version;
|
||||
}
|
||||
} else {
|
||||
acc[feature.feature] = feature.version;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}
|
||||
|
||||
async getFeature<F extends FeatureType>(
|
||||
feature: F
|
||||
): Promise<FeatureConfigType<F> | undefined> {
|
||||
const data = await this.prisma.features.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: {
|
||||
version: 'desc',
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
return getFeature(this.prisma, data.id) as FeatureConfigType<F>;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ======== User Features ========
|
||||
|
||||
async addUserFeature(
|
||||
userId: string,
|
||||
feature: FeatureType,
|
||||
version: number,
|
||||
reason: string,
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
return tx.userFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
expiredAt,
|
||||
activated: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature,
|
||||
version,
|
||||
},
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
})
|
||||
.then(r => r.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* get user's features, will included inactivated features
|
||||
* @param userId user id
|
||||
* @returns list of features
|
||||
*/
|
||||
async getUserFeatures(userId: string) {
|
||||
const features = await this.prisma.userFeatures.findMany({
|
||||
where: {
|
||||
user: { id: userId },
|
||||
feature: {
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureUsers(feature: FeatureType): Promise<UserType[]> {
|
||||
return this.prisma.userFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
feature: {
|
||||
feature: feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(users => users.map(user => user.user));
|
||||
}
|
||||
|
||||
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
userId,
|
||||
activated: true,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
|
||||
// ======== Workspace Features ========
|
||||
|
||||
async addWorkspaceFeature(
|
||||
workspaceId: string,
|
||||
feature: FeatureType,
|
||||
version: number,
|
||||
reason: string,
|
||||
expiredAt?: Date | string
|
||||
) {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.workspaceFeatures.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
return tx.workspaceFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason,
|
||||
expiredAt,
|
||||
activated: true,
|
||||
workspace: {
|
||||
connect: {
|
||||
id: workspaceId,
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature,
|
||||
version,
|
||||
},
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeatures
|
||||
.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
data: {
|
||||
activated: false,
|
||||
},
|
||||
})
|
||||
.then(r => r.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* get workspace's features, will included inactivated features
|
||||
* @param workspaceId workspace id
|
||||
* @returns list of features
|
||||
*/
|
||||
async getWorkspaceFeatures(workspaceId: string) {
|
||||
const features = await this.prisma.workspaceFeatures.findMany({
|
||||
where: {
|
||||
workspace: { id: workspaceId },
|
||||
feature: {
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
activated: true,
|
||||
reason: true,
|
||||
createdAt: true,
|
||||
expiredAt: true,
|
||||
featureId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const configs = await Promise.all(
|
||||
features.map(async feature => ({
|
||||
...feature,
|
||||
feature: await getFeature(this.prisma, feature.featureId),
|
||||
}))
|
||||
);
|
||||
|
||||
return configs.filter(feature => !!feature.feature);
|
||||
}
|
||||
|
||||
async listFeatureWorkspaces(feature: FeatureType): Promise<WorkspaceType[]> {
|
||||
return this.prisma.workspaceFeatures
|
||||
.findMany({
|
||||
where: {
|
||||
activated: true,
|
||||
feature: {
|
||||
feature: feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
public: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(wss => wss.map(ws => ws.workspace as WorkspaceType));
|
||||
}
|
||||
|
||||
async hasWorkspaceFeature(workspaceId: string, feature: FeatureType) {
|
||||
return this.prisma.workspaceFeatures
|
||||
.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
activated: true,
|
||||
feature: {
|
||||
feature,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(count => count > 0);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum FeatureType {
|
||||
Copilot = 'copilot',
|
||||
EarlyAccess = 'early_access',
|
||||
UnlimitedWorkspace = 'unlimited_workspace',
|
||||
}
|
||||
|
||||
registerEnumType(FeatureType, {
|
||||
name: 'FeatureType',
|
||||
description: 'The type of workspace feature',
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureCopilot = z.object({
|
||||
feature: z.literal(FeatureType.Copilot),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.EarlyAccess),
|
||||
configs: z.object({
|
||||
// field polyfill, make it optional in the future
|
||||
whitelist: z.string().array(),
|
||||
}),
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureUnlimitedWorkspace = z.object({
|
||||
feature: z.literal(FeatureType.UnlimitedWorkspace),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { FeatureKind } from '../features';
|
||||
import { OneDay, OneGB, OneMB } from './constant';
|
||||
import { Quota, QuotaType } from './types';
|
||||
|
||||
export const Quotas: Quota[] = [
|
||||
{
|
||||
feature: QuotaType.FreePlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Free',
|
||||
// single blob limit 10MB
|
||||
blobLimit: 10 * OneMB,
|
||||
// total blob limit 10GB
|
||||
storageQuota: 10 * OneGB,
|
||||
// history period of validity 7 days
|
||||
historyPeriod: 7 * OneDay,
|
||||
// member limit 3
|
||||
memberLimit: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: QuotaType.ProPlanV1,
|
||||
type: FeatureKind.Quota,
|
||||
version: 1,
|
||||
configs: {
|
||||
// quota name
|
||||
name: 'Pro',
|
||||
// single blob limit 100MB
|
||||
blobLimit: 100 * OneMB,
|
||||
// total blob limit 100GB
|
||||
storageQuota: 100 * OneGB,
|
||||
// history period of validity 30 days
|
||||
historyPeriod: 30 * OneDay,
|
||||
// member limit 10
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
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[4].feature,
|
||||
version: Quotas[4].version,
|
||||
};
|
||||
|
||||
export const Quota_ProPlanV1 = {
|
||||
feature: Quotas[1].feature,
|
||||
version: Quotas[1].version,
|
||||
};
|
||||
@@ -1,112 +0,0 @@
|
||||
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 { 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
|
||||
) {}
|
||||
|
||||
async getUserQuota(userId: string) {
|
||||
const quota = await this.quota.getUserQuota(userId);
|
||||
|
||||
return {
|
||||
name: quota.feature.name,
|
||||
reason: quota.reason,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: lazy calc, need to be optimized with cache
|
||||
async getUserUsage(userId: string) {
|
||||
const workspaces = await this.permissions.getOwnedWorkspaces(userId);
|
||||
|
||||
const sizes = await Promise.all(
|
||||
workspaces.map(workspace => this.storage.totalSize(workspace))
|
||||
);
|
||||
|
||||
return sizes.reduce((total, size) => total + size, 0);
|
||||
}
|
||||
|
||||
// get workspace's owner quota and total size of used
|
||||
// quota was apply to owner's account
|
||||
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
|
||||
const { user: owner } =
|
||||
await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
if (!owner) throw new NotFoundException('Workspace owner not found');
|
||||
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);
|
||||
|
||||
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) {
|
||||
const { storageQuota, usedSize } =
|
||||
await this.getWorkspaceUsage(workspaceId);
|
||||
|
||||
return storageQuota - (size + usedSize);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { commonFeatureSchema, FeatureKind } from '../features';
|
||||
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',
|
||||
// only for test, smaller quota
|
||||
RestrictedPlanV1 = 'restricted_plan_v1',
|
||||
}
|
||||
|
||||
const quotaPlan = z.object({
|
||||
feature: z.enum([
|
||||
QuotaType.FreePlanV1,
|
||||
QuotaType.ProPlanV1,
|
||||
QuotaType.RestrictedPlanV1,
|
||||
]),
|
||||
configs: z.object({
|
||||
name: z.string(),
|
||||
blobLimit: z.number().positive().int(),
|
||||
storageQuota: z.number().positive().int(),
|
||||
historyPeriod: z.number().positive().int(),
|
||||
memberLimit: z.number().positive().int(),
|
||||
businessBlobLimit: z.number().positive().int().nullish(),
|
||||
}),
|
||||
});
|
||||
|
||||
/// ======== schema infer ========
|
||||
|
||||
export const QuotaSchema = commonFeatureSchema
|
||||
.extend({
|
||||
type: z.literal(FeatureKind.Quota),
|
||||
})
|
||||
.and(z.discriminatedUnion('feature', [quotaPlan]));
|
||||
|
||||
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)
|
||||
name!: string;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
blobLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
historyPeriod!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
memberLimit!: number;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
storageQuota!: number;
|
||||
|
||||
@Field(() => HumanReadableQuotaType)
|
||||
humanReadable!: HumanReadableQuotaType;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
usedSize!: number;
|
||||
}
|
||||
|
||||
/// ======== utils ========
|
||||
|
||||
export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(OneKB));
|
||||
|
||||
return (
|
||||
parseFloat((bytes / Math.pow(OneKB, i)).toFixed(dm)) + ' ' + ByteUnit[i]
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
|
||||
|
||||
@Module({
|
||||
providers: [WorkspaceBlobStorage, AvatarStorage],
|
||||
exports: [WorkspaceBlobStorage, AvatarStorage],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
export { AvatarStorage, WorkspaceBlobStorage };
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BlobInputType,
|
||||
EventPayload,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import { Config, createStorageProvider, OnEvent } from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class AvatarStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
private readonly storageConfig: Config['storage']['storages']['avatar'];
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'avatar');
|
||||
this.storageConfig = this.config.storage.storages.avatar;
|
||||
}
|
||||
|
||||
async put(key: string, blob: BlobInputType, metadata?: PutObjectMetadata) {
|
||||
await this.provider.put(key, blob, metadata);
|
||||
let link = this.storageConfig.publicLinkFactory(key);
|
||||
|
||||
if (link.startsWith('/')) {
|
||||
link = this.config.baseUrl + link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
return this.provider.get(key);
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
return this.provider.delete(key);
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
if (user.avatarUrl) {
|
||||
await this.delete(user.avatarUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
BlobInputType,
|
||||
EventPayload,
|
||||
StorageProvider,
|
||||
} from '../../../fundamentals';
|
||||
import {
|
||||
Config,
|
||||
createStorageProvider,
|
||||
EventEmitter,
|
||||
OnEvent,
|
||||
} from '../../../fundamentals';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceBlobStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
|
||||
constructor(
|
||||
private readonly event: EventEmitter,
|
||||
private readonly config: Config
|
||||
) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'blob');
|
||||
}
|
||||
|
||||
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||
await this.provider.put(`${workspaceId}/${key}`, blob);
|
||||
}
|
||||
|
||||
async get(workspaceId: string, key: string) {
|
||||
return this.provider.get(`${workspaceId}/${key}`);
|
||||
}
|
||||
|
||||
async list(workspaceId: string) {
|
||||
const blobs = await this.provider.list(workspaceId + '/');
|
||||
|
||||
blobs.forEach(item => {
|
||||
// trim workspace prefix
|
||||
item.key = item.key.slice(workspaceId.length + 1);
|
||||
});
|
||||
|
||||
return blobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* we won't really delete the blobs until the doc blobs manager is implemented sounded
|
||||
*/
|
||||
async delete(_workspaceId: string, _key: string) {
|
||||
// return this.provider.delete(`${workspaceId}/${key}`);
|
||||
}
|
||||
|
||||
async totalSize(workspaceId: string) {
|
||||
const blobs = await this.list(workspaceId);
|
||||
// how could we ignore the ones get soft-deleted?
|
||||
return blobs.reduce((acc, item) => acc + item.size, 0);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.deleted')
|
||||
async onWorkspaceDeleted(workspaceId: EventPayload<'workspace.deleted'>) {
|
||||
const blobs = await this.list(workspaceId);
|
||||
|
||||
// to reduce cpu time holding
|
||||
blobs.forEach(blob => {
|
||||
this.event.emit('workspace.blob.deleted', {
|
||||
workspaceId: workspaceId,
|
||||
name: blob.key,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.blob.deleted')
|
||||
async onDeleteWorkspaceBlob({
|
||||
workspaceId,
|
||||
name,
|
||||
}: EventPayload<'workspace.blob.deleted'>) {
|
||||
await this.delete(workspaceId, name);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { AvatarStorage } from './avatar';
|
||||
export { WorkspaceBlobStorage } from './blob';
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@Controller('/api/avatars')
|
||||
export class UserAvatarController {
|
||||
constructor(private readonly storage: AvatarStorage) {}
|
||||
|
||||
@Get('/:id')
|
||||
async getAvatar(@Res() res: Response, @Param('id') id: string) {
|
||||
if (this.storage.provider.type !== 'fs') {
|
||||
throw new ForbiddenException(
|
||||
'Only available when avatar storage provider set to fs.'
|
||||
);
|
||||
}
|
||||
|
||||
const { body, metadata } = await this.storage.get(id);
|
||||
|
||||
if (!body) {
|
||||
throw new NotFoundException(`Avatar ${id} not found.`);
|
||||
}
|
||||
|
||||
// metadata should always exists if body is not null
|
||||
if (metadata) {
|
||||
res.setHeader('content-type', metadata.contentType);
|
||||
res.setHeader('last-modified', metadata.lastModified.toISOString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
}
|
||||
|
||||
body.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../auth/guard';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { UserType } from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
* User resolver
|
||||
* All op rate limit: 10 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => UserType)
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly users: UsersService,
|
||||
private readonly feature: FeatureManagementService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addToEarlyAccess(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (user) {
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
} else {
|
||||
const user = await this.auth.createAnonymousUser(email);
|
||||
return this.feature.addEarlyAccess(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeEarlyAccess(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('email') email: string
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User ${email} not found`);
|
||||
}
|
||||
return this.feature.removeEarlyAccess(user.id);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Query(() => [UserType])
|
||||
async earlyAccessUsers(
|
||||
@Context() ctx: { isAdminQuery: boolean },
|
||||
@CurrentUser() user: UserType
|
||||
): Promise<UserType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
// allow query other user's subscription
|
||||
ctx.isAdminQuery = true;
|
||||
return this.feature.listEarlyAccess();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { FeatureManagementService, FeatureType } from '../features';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceType } from './types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceManagementResolver {
|
||||
constructor(
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async addWorkspaceFeature(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<number> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.addWorkspaceFeatures(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Mutation(() => Int)
|
||||
async removeWorkspaceFeature(
|
||||
@CurrentUser() currentUser: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<boolean> {
|
||||
if (!this.feature.isStaff(currentUser.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Query(() => [WorkspaceType])
|
||||
async listWorkspaceFeatures(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType
|
||||
): Promise<WorkspaceType[]> {
|
||||
if (!this.feature.isStaff(user.email)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
return this.feature.listFeatureWorkspaces(feature);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async setWorkspaceExperimentalFeature(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('feature', { type: () => FeatureType }) feature: FeatureType,
|
||||
@Args('enable') enable: boolean
|
||||
): Promise<boolean> {
|
||||
if (!(await this.feature.canEarlyAccess(user.email))) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
const owner = await this.permission.getWorkspaceOwner(workspaceId);
|
||||
const availableFeatures = await this.availableFeatures(user);
|
||||
if (owner.user.id !== user.id || !availableFeatures.includes(feature)) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
return await this.feature
|
||||
.addWorkspaceFeatures(
|
||||
workspaceId,
|
||||
feature,
|
||||
undefined,
|
||||
'add by experimental feature api'
|
||||
)
|
||||
.then(id => id > 0);
|
||||
} else {
|
||||
return await this.feature.removeWorkspaceFeature(workspaceId, feature);
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Available features of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async availableFeatures(
|
||||
@CurrentUser() user: UserType
|
||||
): Promise<FeatureType[]> {
|
||||
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
|
||||
if (isEarlyAccessUser) {
|
||||
return [FeatureType.Copilot];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => [FeatureType], {
|
||||
description: 'Enabled features of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async features(@Parent() workspace: WorkspaceType): Promise<FeatureType[]> {
|
||||
return this.feature.getWorkspaceFeatures(workspace.id);
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
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';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceBlobSizes, WorkspaceType } from '../types';
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
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,
|
||||
})
|
||||
async blobsSize(@Parent() workspace: WorkspaceType) {
|
||||
return this.storage.totalSize(workspace.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `workspace.blobs` instead
|
||||
*/
|
||||
@Query(() => [String], {
|
||||
description: 'List blobs of workspace',
|
||||
deprecationReason: 'use `workspace.blobs` instead',
|
||||
})
|
||||
@MakeCache(['blobs'], ['workspaceId'])
|
||||
async listBlobs(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||
|
||||
return this.storage
|
||||
.list(workspaceId)
|
||||
.then(list => list.map(item => item.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `user.storageUsage` instead
|
||||
*/
|
||||
@Query(() => WorkspaceBlobSizes, {
|
||||
deprecationReason: 'use `user.storageUsage` instead',
|
||||
})
|
||||
async collectAllBlobSizes(@CurrentUser() user: UserType) {
|
||||
const size = await this.quota.getUserUsage(user.id);
|
||||
return { size };
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated mutation `setBlob` will check blob limit & quota usage
|
||||
*/
|
||||
@Query(() => WorkspaceBlobSizes, {
|
||||
deprecationReason: 'no more needed',
|
||||
})
|
||||
async checkBlobSize(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('size', { type: () => SafeIntResolver }) blobSize: number
|
||||
) {
|
||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
if (canWrite) {
|
||||
const size = await this.quota.checkBlobQuota(workspaceId, blobSize);
|
||||
return { size };
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async setBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||
blob: FileUpload
|
||||
) {
|
||||
await this.permissions.checkWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
|
||||
const { storageQuota, usedSize, businessBlobLimit } =
|
||||
await this.quota.getWorkspaceUsage(workspaceId);
|
||||
|
||||
const unlimited = await this.feature.hasWorkspaceFeature(
|
||||
workspaceId,
|
||||
FeatureType.UnlimitedWorkspace
|
||||
);
|
||||
|
||||
const checkExceeded = (recvSize: number) => {
|
||||
if (!storageQuota) {
|
||||
throw new GraphQLError('cannot find user quota', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
const total = usedSize + recvSize;
|
||||
// only skip total storage check if workspace has unlimited feature
|
||||
if (total > storageQuota && !unlimited) {
|
||||
this.logger.log(
|
||||
`storage size limit exceeded: ${total} > ${storageQuota}`
|
||||
);
|
||||
return true;
|
||||
} else if (recvSize > businessBlobLimit) {
|
||||
this.logger.log(
|
||||
`blob size limit exceeded: ${recvSize} > ${businessBlobLimit}`
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (checkExceeded(0)) {
|
||||
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();
|
||||
const chunks: Uint8Array[] = [];
|
||||
stream.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
|
||||
// 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 GraphQLError('storage or blob size limit exceeded', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => {
|
||||
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,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
resolve(buffer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await this.storage.put(workspaceId, blob.filename, buffer);
|
||||
return blob.filename;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@PreventCache(['blobs'], ['workspaceId'])
|
||||
async deleteBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('hash') name: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||
|
||||
await this.storage.delete(workspaceId, name);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './blob';
|
||||
export * from './history';
|
||||
export * from './page';
|
||||
export * from './workspace';
|
||||
@@ -1,162 +0,0 @@
|
||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
|
||||
|
||||
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';
|
||||
|
||||
registerEnumType(PublicPageMode, {
|
||||
name: 'PublicPageMode',
|
||||
description: 'The mode which the public page default in',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
class WorkspacePage implements Partial<PrismaWorkspacePage> {
|
||||
@Field(() => String, { name: 'id' })
|
||||
pageId!: string;
|
||||
|
||||
@Field()
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => PublicPageMode)
|
||||
mode!: PublicPageMode;
|
||||
|
||||
@Field()
|
||||
public!: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class PagePermissionResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@ResolveField(() => [String], {
|
||||
description: 'Shared pages of workspace',
|
||||
complexity: 2,
|
||||
deprecationReason: 'use WorkspaceType.publicPages',
|
||||
})
|
||||
async sharedPages(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.workspacePage.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
public: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(row => row.pageId);
|
||||
}
|
||||
|
||||
@ResolveField(() => [WorkspacePage], {
|
||||
description: 'Public pages of a workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async publicPages(@Parent() workspace: WorkspaceType) {
|
||||
return this.prisma.workspacePage.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
public: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'sharePage',
|
||||
deprecationReason: 'renamed to publicPage',
|
||||
})
|
||||
async deprecatedSharePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.publishPage(user, workspaceId, pageId, PublicPageMode.Page);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async publishPage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string,
|
||||
@Args({
|
||||
name: 'mode',
|
||||
type: () => PublicPageMode,
|
||||
nullable: true,
|
||||
defaultValue: PublicPageMode.Page,
|
||||
})
|
||||
mode: PublicPageMode
|
||||
) {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
docId.workspace,
|
||||
user.id,
|
||||
Permission.Read
|
||||
);
|
||||
|
||||
return this.permission.publishPage(docId.workspace, docId.guid, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'revokePage',
|
||||
deprecationReason: 'use revokePublicPage',
|
||||
})
|
||||
async deprecatedRevokePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.revokePublicPage(user, workspaceId, pageId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspacePage)
|
||||
async revokePublicPage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
const docId = new DocID(pageId, workspaceId);
|
||||
|
||||
if (docId.isWorkspace) {
|
||||
throw new ForbiddenException('Expect page not to be workspace');
|
||||
}
|
||||
|
||||
await this.permission.checkWorkspace(
|
||||
docId.workspace,
|
||||
user.id,
|
||||
Permission.Read
|
||||
);
|
||||
|
||||
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
||||
}
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} 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 {
|
||||
CloudThrottlerGuard,
|
||||
EventEmitter,
|
||||
type FileUpload,
|
||||
MailService,
|
||||
PrismaService,
|
||||
Throttle,
|
||||
} from '../../../fundamentals';
|
||||
import { Auth, CurrentUser, Public } from '../../auth';
|
||||
import { AuthService } from '../../auth/service';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UsersService, UserType } from '../../users';
|
||||
import { PermissionService } from '../permission';
|
||||
import {
|
||||
InvitationType,
|
||||
InviteUserType,
|
||||
Permission,
|
||||
UpdateWorkspaceInput,
|
||||
WorkspaceType,
|
||||
} from '../types';
|
||||
import { defaultWorkspaceAvatar } from '../utils';
|
||||
|
||||
/**
|
||||
* Workspace resolver
|
||||
* Public apis rate limit: 10 req/m
|
||||
* Other rate limit: 120 req/m
|
||||
*/
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Auth()
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
private readonly logger = new Logger(WorkspaceResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly users: UsersService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly blobStorage: WorkspaceBlobStorage
|
||||
) {}
|
||||
|
||||
@ResolveField(() => Permission, {
|
||||
description: 'Permission of current signed in user in workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async permission(
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
if ('permission' in workspace) {
|
||||
return workspace.permission;
|
||||
}
|
||||
|
||||
const permission = await this.permissions.get(workspace.id, user.id);
|
||||
|
||||
if (!permission) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return permission;
|
||||
}
|
||||
|
||||
@ResolveField(() => Int, {
|
||||
description: 'member count of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
memberCount(@Parent() workspace: WorkspaceType) {
|
||||
return this.prisma.workspaceUserPermission.count({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async owner(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.permissions.getWorkspaceOwner(workspace.id);
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@ResolveField(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async members(
|
||||
@Parent() workspace: WorkspaceType,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
|
||||
@Args('take', { type: () => Int, nullable: true }) take?: number
|
||||
) {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
skip,
|
||||
take: take || 8,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: 'asc',
|
||||
},
|
||||
{
|
||||
type: 'desc',
|
||||
},
|
||||
],
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data
|
||||
.filter(({ user }) => !!user)
|
||||
.map(({ id, accepted, type, user }) => ({
|
||||
...user,
|
||||
permission: type,
|
||||
inviteId: id,
|
||||
accepted,
|
||||
}));
|
||||
}
|
||||
|
||||
@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,
|
||||
})
|
||||
async isOwner(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
const data = await this.permissions.tryGetWorkspaceOwner(workspaceId);
|
||||
|
||||
return data?.user?.id === user.id;
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
description: 'Get all accessible workspaces for current user',
|
||||
complexity: 2,
|
||||
})
|
||||
async workspaces(@CurrentUser() user: User) {
|
||||
const data = await this.prisma.workspaceUserPermission.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
include: {
|
||||
workspace: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(({ workspace, type }) => {
|
||||
return {
|
||||
...workspace,
|
||||
permission: type,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Throttle({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 30,
|
||||
},
|
||||
})
|
||||
@Public()
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get public workspace by id',
|
||||
})
|
||||
async publicWorkspace(@Args('id') id: string) {
|
||||
const workspace = await this.prisma.workspace.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (workspace?.public) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissions.checkWorkspace(id, user.id);
|
||||
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
// we no longer support init workspace with a preload file
|
||||
// use sync system to uploading them once created
|
||||
@Args({ name: 'init', type: () => GraphQLUpload, nullable: true })
|
||||
init: FileUpload | null
|
||||
) {
|
||||
const workspace = await this.prisma.workspace.create({
|
||||
data: {
|
||||
public: false,
|
||||
permissions: {
|
||||
create: {
|
||||
type: Permission.Owner,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (init) {
|
||||
// convert stream to buffer
|
||||
const buffer = await new Promise<Buffer>(resolve => {
|
||||
const stream = init.createReadStream();
|
||||
const chunks: Uint8Array[] = [];
|
||||
stream.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on('error', () => {
|
||||
resolve(Buffer.from([]));
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
|
||||
if (buffer.length) {
|
||||
await this.prisma.snapshot.create({
|
||||
data: {
|
||||
id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
blob: buffer,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@Mutation(() => WorkspaceType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
await this.permissions.checkWorkspace(id, user.id, Permission.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissions.checkWorkspace(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
this.event.emit('workspace.deleted', id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
async invite(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('email') email: string,
|
||||
@Args('permission', { type: () => Permission }) permission: Permission,
|
||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
||||
) {
|
||||
await this.permissions.checkWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Admin
|
||||
);
|
||||
|
||||
if (permission === Permission.Owner) {
|
||||
throw new ForbiddenException('Cannot change owner');
|
||||
}
|
||||
|
||||
// member limit check
|
||||
const [memberCount, quota] = await Promise.all([
|
||||
this.prisma.workspaceUserPermission.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
this.quota.getWorkspaceUsage(workspaceId),
|
||||
]);
|
||||
if (memberCount >= quota.memberLimit) {
|
||||
throw new 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: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.auth.createAnonymousUser(email);
|
||||
}
|
||||
|
||||
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({
|
||||
default: {
|
||||
limit: 10,
|
||||
ttl: 30,
|
||||
},
|
||||
})
|
||||
@Public()
|
||||
@Query(() => InvitationType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
||||
const workspaceId = await this.prisma.workspaceUserPermission
|
||||
.findUniqueOrThrow({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
},
|
||||
})
|
||||
.then(({ workspaceId }) => workspaceId);
|
||||
|
||||
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const doc = new Doc();
|
||||
|
||||
applyUpdate(doc, new Uint8Array(snapshot.blob));
|
||||
const metaJSON = doc.getMap('meta').toJSON();
|
||||
|
||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
const invitee = await this.permissions.getWorkspaceInvitation(
|
||||
inviteId,
|
||||
workspaceId
|
||||
);
|
||||
|
||||
let avatar = '';
|
||||
|
||||
if (metaJSON.avatar) {
|
||||
const avatarBlob = await this.blobStorage.get(
|
||||
workspaceId,
|
||||
metaJSON.avatar
|
||||
);
|
||||
|
||||
if (avatarBlob.body) {
|
||||
avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
name: metaJSON.name || '',
|
||||
avatar: avatar || defaultWorkspaceAvatar,
|
||||
id: workspaceId,
|
||||
},
|
||||
user: owner.user,
|
||||
invitee: invitee.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revoke(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
await this.permissions.checkWorkspace(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Admin
|
||||
);
|
||||
|
||||
return this.permissions.revokeWorkspace(workspaceId, userId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Public()
|
||||
async acceptInviteById(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('inviteId') inviteId: string,
|
||||
@Args('sendAcceptMail', { nullable: true }) sendAcceptMail: boolean
|
||||
) {
|
||||
const {
|
||||
invitee,
|
||||
user: inviter,
|
||||
workspace,
|
||||
} = await this.getInviteInfo(inviteId);
|
||||
|
||||
if (!inviter || !invitee) {
|
||||
throw new ForbiddenException(
|
||||
`can not find inviter/invitee by inviteId: ${inviteId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (sendAcceptMail) {
|
||||
await this.mailer.sendAcceptedEmail(inviter.email, {
|
||||
inviteeName: invitee.name,
|
||||
workspaceName: workspace.name,
|
||||
});
|
||||
}
|
||||
|
||||
return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async leaveWorkspace(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('workspaceName') workspaceName: string,
|
||||
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
|
||||
) {
|
||||
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||
|
||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||
|
||||
if (!owner.user) {
|
||||
throw new ForbiddenException(
|
||||
`can not find owner by workspaceId: ${workspaceId}`
|
||||
);
|
||||
}
|
||||
|
||||
if (sendLeaveMail) {
|
||||
await this.mailer.sendLeaveWorkspaceEmail(owner.user.email, {
|
||||
workspaceName,
|
||||
inviteeName: user.name,
|
||||
});
|
||||
}
|
||||
|
||||
return this.permissions.revokeWorkspace(workspaceId, user.id);
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import {
|
||||
Field,
|
||||
ID,
|
||||
InputType,
|
||||
ObjectType,
|
||||
OmitType,
|
||||
PartialType,
|
||||
PickType,
|
||||
registerEnumType,
|
||||
} from '@nestjs/graphql';
|
||||
import type { Workspace } from '@prisma/client';
|
||||
import { SafeIntResolver } from 'graphql-scalars';
|
||||
|
||||
import { UserType } from '../users/types';
|
||||
|
||||
export enum Permission {
|
||||
Read = 0,
|
||||
Write = 1,
|
||||
Admin = 10,
|
||||
Owner = 99,
|
||||
}
|
||||
|
||||
registerEnumType(Permission, {
|
||||
name: 'Permission',
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class InviteUserType extends OmitType(
|
||||
PartialType(UserType),
|
||||
['id'],
|
||||
ObjectType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => Permission, { description: 'User permission in workspace' })
|
||||
permission!: Permission;
|
||||
|
||||
@Field({ description: 'Invite id' })
|
||||
inviteId!: string;
|
||||
|
||||
@Field({ description: 'User accepted' })
|
||||
accepted!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'is Public workspace' })
|
||||
public!: boolean;
|
||||
|
||||
@Field({ description: 'Workspace created date' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
members!: InviteUserType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationWorkspaceType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Workspace name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
// nullable: true,
|
||||
description: 'Base64 encoded avatar',
|
||||
})
|
||||
avatar!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceBlobSizes {
|
||||
@Field(() => SafeIntResolver)
|
||||
size!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationType {
|
||||
@Field({ description: 'Workspace information' })
|
||||
workspace!: InvitationWorkspaceType;
|
||||
@Field({ description: 'User information' })
|
||||
user!: UserType;
|
||||
@Field({ description: 'Invitee information' })
|
||||
invitee!: UserType;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateWorkspaceInput extends PickType(
|
||||
PartialType(WorkspaceType),
|
||||
['public'],
|
||||
InputType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
|
||||
import { AppModule as BusinessAppModule } from '../app.module';
|
||||
import { ConfigModule } from '../fundamentals/config';
|
||||
import { PrismaModule } from '../prisma';
|
||||
import { CreateCommand, NameQuestion } from './commands/create';
|
||||
import { RevertCommand, RunCommand } from './commands/run';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
doc: {
|
||||
manager: {
|
||||
enableUpdateAutoMerging: false,
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
BusinessAppModule,
|
||||
],
|
||||
imports: [PrismaModule],
|
||||
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
|
||||
})
|
||||
export class CliAppModule {}
|
||||
class AppModule {}
|
||||
|
||||
async function bootstrap() {
|
||||
await CommandFactory.run(AppModule, new Logger());
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
|
||||
@@ -59,13 +59,13 @@ export class CreateCommand extends CommandRunner {
|
||||
}
|
||||
|
||||
private createScript(name: string) {
|
||||
const contents = ["import { PrismaClient } from '@prisma/client';", ''];
|
||||
const contents = ["import { PrismaService } from '../../prisma';", ''];
|
||||
contents.push(`export class ${name} {`);
|
||||
contents.push(' // do the migration');
|
||||
contents.push(' static async up(db: PrismaClient) {}');
|
||||
contents.push(' static async up(db: PrismaService) {}');
|
||||
contents.push('');
|
||||
contents.push(' // revert the migration');
|
||||
contents.push(' static async down(db: PrismaClient) {}');
|
||||
contents.push(' static async down(db: PrismaService) {}');
|
||||
|
||||
contents.push('}');
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import { join } from 'node:path';
|
||||
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: PrismaClient, injector: ModuleRef) => Promise<void>;
|
||||
down: (db: PrismaClient, injector: ModuleRef) => Promise<void>;
|
||||
up: (db: PrismaService) => Promise<void>;
|
||||
down: (db: PrismaService) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function collectMigrations(): Promise<Migration[]> {
|
||||
@@ -46,10 +46,7 @@ export async function collectMigrations(): Promise<Migration[]> {
|
||||
})
|
||||
export class RunCommand extends CommandRunner {
|
||||
logger = new Logger(RunCommand.name);
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly injector: ModuleRef
|
||||
) {
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -106,14 +103,14 @@ export class RunCommand extends CommandRunner {
|
||||
});
|
||||
|
||||
try {
|
||||
await migration.up(this.db, this.injector);
|
||||
await migration.up(this.db);
|
||||
} catch (e) {
|
||||
await this.db.dataMigration.delete({
|
||||
where: {
|
||||
id: record.id,
|
||||
},
|
||||
});
|
||||
await migration.down(this.db, this.injector);
|
||||
await migration.down(this.db);
|
||||
this.logger.error('Failed to run data migration', e);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -137,10 +134,7 @@ export class RunCommand extends CommandRunner {
|
||||
export class RevertCommand extends CommandRunner {
|
||||
logger = new Logger(RevertCommand.name);
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly injector: ModuleRef
|
||||
) {
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -174,7 +168,7 @@ export class RevertCommand extends CommandRunner {
|
||||
|
||||
try {
|
||||
this.logger.log(`Reverting ${name}...`);
|
||||
await migration.down(this.db, this.injector);
|
||||
await migration.down(this.db);
|
||||
this.logger.log('Done reverting');
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to revert data migration ${name}`, e);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user