mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-05 00:54:56 +00:00
Compare commits
2 Commits
v0.11.10
...
v0.11.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55792e2f41 | ||
|
|
04e7a9fc14 |
@@ -11,4 +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
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
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/deploy/deploy.mjs
vendored
2
.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,
|
||||
@@ -95,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: .
|
||||
|
||||
18
.github/actions/setup-node/action.yml
vendored
18
.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
|
||||
@@ -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/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
|
||||
@@ -136,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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -34,6 +34,7 @@ app:
|
||||
accountId: ''
|
||||
accessKeyId: ''
|
||||
secretAccessKey: ''
|
||||
bucket: ''
|
||||
oauth:
|
||||
email:
|
||||
secretName: 'oauth-email'
|
||||
|
||||
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:
|
||||
|
||||
36
.github/renovate.json
vendored
36
.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,32 +30,16 @@
|
||||
"@prisma/instrumentation",
|
||||
"prisma"
|
||||
],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "prisma"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["^@electron-forge"],
|
||||
"rangeStrategy": "replace",
|
||||
"groupName": "electron-forge"
|
||||
},
|
||||
{
|
||||
"groupName": "blocksuite-nightly",
|
||||
"matchPackagePatterns": ["^@blocksuite"],
|
||||
"excludePackageNames": ["@blocksuite/icons"],
|
||||
"rangeStrategy": "replace",
|
||||
"followTag": "nightly"
|
||||
},
|
||||
{
|
||||
"groupName": "all non-major dependencies",
|
||||
"groupSlug": "all-minor-patch",
|
||||
"matchPackagePatterns": ["*"],
|
||||
"excludePackagePatterns": ["^@blocksuite/"],
|
||||
"matchUpdateTypes": ["minor", "patch"]
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["*"],
|
||||
"rangeStrategy": "replace",
|
||||
"excludePackagePatterns": ["^@blocksuite/"]
|
||||
}
|
||||
],
|
||||
"commitMessagePrefix": "chore: ",
|
||||
|
||||
99
.github/workflows/build-test.yml
vendored
99
.github/workflows/build-test.yml
vendored
@@ -108,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
|
||||
@@ -131,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
|
||||
@@ -156,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
|
||||
@@ -178,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
|
||||
@@ -216,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
|
||||
@@ -229,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 }}
|
||||
@@ -247,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:
|
||||
@@ -254,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
|
||||
@@ -270,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
|
||||
@@ -277,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
|
||||
@@ -315,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;"
|
||||
@@ -343,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:
|
||||
@@ -407,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;"
|
||||
@@ -438,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: |
|
||||
@@ -448,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
|
||||
@@ -512,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
|
||||
@@ -520,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
|
||||
@@ -553,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
|
||||
24
.github/workflows/deploy.yml
vendored
24
.github/workflows/deploy.yml
vendored
@@ -23,7 +23,6 @@ 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
|
||||
@@ -32,7 +31,7 @@ jobs:
|
||||
- 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
|
||||
@@ -44,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,7 +63,7 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
@@ -76,7 +76,6 @@ 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
|
||||
@@ -87,7 +86,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
|
||||
@@ -100,7 +99,6 @@ 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
|
||||
@@ -111,7 +109,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.arm64.node
|
||||
path: ./packages/backend/storage/storage.node
|
||||
@@ -128,22 +126,22 @@ jobs:
|
||||
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
|
||||
@@ -222,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
|
||||
@@ -240,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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -146,7 +155,7 @@ jobs:
|
||||
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 }}
|
||||
|
||||
@@ -16,8 +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/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
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -2024,14 +2024,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.6"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc"
|
||||
checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
@@ -2460,9 +2462,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
|
||||
17
README.md
17
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:
|
||||
@@ -212,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.10.3-canary.2"
|
||||
}
|
||||
@@ -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!}
|
||||
25
package.json
25
package.json
@@ -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,7 +33,7 @@
|
||||
"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",
|
||||
@@ -55,12 +58,13 @@
|
||||
"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",
|
||||
"@nx/vite": "17.1.3",
|
||||
"@perfsee/sdk": "^1.9.0",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@taplo/cli": "^0.5.2",
|
||||
@@ -74,8 +78,8 @@
|
||||
"@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",
|
||||
"@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",
|
||||
@@ -84,10 +88,10 @@
|
||||
"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",
|
||||
"fake-indexeddb": "5.0.1",
|
||||
"happy-dom": "^12.10.3",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
@@ -96,7 +100,7 @@
|
||||
"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",
|
||||
@@ -107,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"
|
||||
},
|
||||
@@ -172,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,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;
|
||||
@@ -9,18 +9,18 @@
|
||||
},
|
||||
"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/app.ts",
|
||||
"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",
|
||||
"@keyv/redis": "^2.8.0",
|
||||
@@ -36,29 +36,29 @@
|
||||
"@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.19.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.46.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.19.0",
|
||||
"@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.46.0",
|
||||
"@opentelemetry/instrumentation": "^0.45.1",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.46.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.35.0",
|
||||
"@opentelemetry/resources": "^1.19.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.19.0",
|
||||
"@opentelemetry/sdk-node": "^0.46.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.19.0",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/instrumentation": "^5.7.1",
|
||||
"@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",
|
||||
"express": "^4.18.2",
|
||||
"file-type": "^19.0.0",
|
||||
"file-type": "^18.7.0",
|
||||
"get-stream": "^8.0.1",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
@@ -74,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",
|
||||
@@ -101,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
// Custom configurations
|
||||
|
||||
const env = process.env;
|
||||
const node = AFFiNE.node;
|
||||
|
||||
// TODO: may be separate config overring in `affine.[env].config`?
|
||||
if (node.prod && env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||
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'
|
||||
}`;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { getDefaultAFFiNEConfig } from './config/default';
|
||||
|
||||
globalThis.AFFiNE = getDefaultAFFiNEConfig();
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
const BasicModules = [
|
||||
@@ -16,6 +17,7 @@ const BasicModules = [
|
||||
ConfigModule.forRoot(),
|
||||
CacheModule,
|
||||
EventModule,
|
||||
StorageModule.forRoot(),
|
||||
SessionModule,
|
||||
RateLimiterModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||
|
||||
import type { LeafPaths } from '../utils/types';
|
||||
import type { AFFiNEStorageConfig } from './storage';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
@@ -166,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
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/// <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';
|
||||
import { getDefaultAFFiNEStorageConfig } from './storage';
|
||||
|
||||
export const SERVER_FLAVOR = (process.env.SERVER_FLAVOR ??
|
||||
'allinone') as ServerFlavor;
|
||||
@@ -58,6 +59,11 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
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'],
|
||||
@@ -174,7 +180,18 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
storage: getDefaultAFFiNEStorageConfig(),
|
||||
objectStorage: {
|
||||
r2: {
|
||||
enabled: false,
|
||||
bucket: '',
|
||||
accountId: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
},
|
||||
fs: {
|
||||
path: join(homedir(), '.affine-storage'),
|
||||
},
|
||||
},
|
||||
rateLimiter: {
|
||||
ttl: 60,
|
||||
limit: 60,
|
||||
|
||||
@@ -74,4 +74,3 @@ export class ConfigModule {
|
||||
|
||||
export type { AFFiNEConfig } from './def';
|
||||
export { SERVER_FLAVOR } from './default';
|
||||
export * from './storage';
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { S3ClientConfigType } from '@aws-sdk/client-s3';
|
||||
|
||||
export type StorageProviderType = 'fs' | 'r2' | 's3';
|
||||
export interface FsStorageConfig {
|
||||
path: string;
|
||||
}
|
||||
export type R2StorageConfig = S3ClientConfigType & {
|
||||
accountId: string;
|
||||
};
|
||||
export type S3StorageConfig = S3ClientConfigType;
|
||||
|
||||
export type StorageTargetConfig<Ext = unknown> = {
|
||||
provider: StorageProviderType;
|
||||
bucket: string;
|
||||
} & Ext;
|
||||
|
||||
export interface AFFiNEStorageConfig {
|
||||
/**
|
||||
* All providers for object storage
|
||||
*
|
||||
* Support different providers for different usage at the same time.
|
||||
*/
|
||||
providers: {
|
||||
fs?: FsStorageConfig;
|
||||
s3?: S3StorageConfig;
|
||||
r2?: R2StorageConfig;
|
||||
};
|
||||
storages: {
|
||||
avatar: StorageTargetConfig<{ publicLinkFactory: (key: string) => string }>;
|
||||
blob: StorageTargetConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export type StorageProviders = AFFiNEStorageConfig['providers'];
|
||||
export type Storages = keyof AFFiNEStorageConfig['storages'];
|
||||
|
||||
export function getDefaultAFFiNEStorageConfig(): AFFiNEStorageConfig {
|
||||
return {
|
||||
providers: {
|
||||
fs: {
|
||||
path: join(homedir(), '.affine/storage'),
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
avatar: {
|
||||
provider: 'fs',
|
||||
bucket: 'avatars',
|
||||
publicLinkFactory: key => `/api/avatars/${key}`,
|
||||
},
|
||||
blob: {
|
||||
provider: 'fs',
|
||||
bucket: 'blobs',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,18 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
|
||||
import { AppModule as BusinessAppModule } from '../app';
|
||||
import { ConfigModule } from '../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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
BusinessAppModule,
|
||||
],
|
||||
imports: [PrismaModule],
|
||||
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
async function bootstrap() {
|
||||
await CommandFactory.run(AppModule, new Logger()).catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
process.exit(0);
|
||||
await CommandFactory.run(AppModule, new Logger());
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
|
||||
@@ -3,7 +3,6 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
@@ -11,8 +10,8 @@ import { PrismaService } from '../../prisma';
|
||||
interface Migration {
|
||||
file: string;
|
||||
name: string;
|
||||
up: (db: PrismaService, injector: ModuleRef) => Promise<void>;
|
||||
down: (db: PrismaService, injector: ModuleRef) => Promise<void>;
|
||||
up: (db: PrismaService) => Promise<void>;
|
||||
down: (db: PrismaService) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function collectMigrations(): Promise<Migration[]> {
|
||||
@@ -47,10 +46,7 @@ export async function collectMigrations(): Promise<Migration[]> {
|
||||
})
|
||||
export class RunCommand extends CommandRunner {
|
||||
logger = new Logger(RunCommand.name);
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
private readonly injector: ModuleRef
|
||||
) {
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -107,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);
|
||||
}
|
||||
@@ -138,10 +134,7 @@ export class RunCommand extends CommandRunner {
|
||||
export class RevertCommand extends CommandRunner {
|
||||
logger = new Logger(RevertCommand.name);
|
||||
|
||||
constructor(
|
||||
private readonly db: PrismaService,
|
||||
private readonly injector: ModuleRef
|
||||
) {
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -175,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);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Features } from '../../modules/features';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
CommonFeature,
|
||||
FeatureKind,
|
||||
Features,
|
||||
FeatureType,
|
||||
} from '../../modules/features';
|
||||
import { Quotas } from '../../modules/quota/schema';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { migrateNewFeatureTable, upsertFeature } from './utils/user-features';
|
||||
|
||||
export class UserFeaturesInit1698652531198 {
|
||||
// do the migration
|
||||
@@ -22,3 +28,95 @@ export class UserFeaturesInit1698652531198 {
|
||||
// TODO: revert the migration
|
||||
}
|
||||
}
|
||||
|
||||
// upgrade features from lower version to higher version
|
||||
async function upsertFeature(
|
||||
db: PrismaService,
|
||||
feature: CommonFeature
|
||||
): Promise<void> {
|
||||
const hasEqualOrGreaterVersion =
|
||||
(await db.features.count({
|
||||
where: {
|
||||
feature: feature.feature,
|
||||
version: {
|
||||
gte: feature.version,
|
||||
},
|
||||
},
|
||||
})) > 0;
|
||||
// will not update exists version
|
||||
if (!hasEqualOrGreaterVersion) {
|
||||
await db.features.create({
|
||||
data: {
|
||||
feature: feature.feature,
|
||||
type: feature.type,
|
||||
version: feature.version,
|
||||
configs: feature.configs as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateNewFeatureTable(prisma: PrismaService) {
|
||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||
for (const oldUser of waitingList) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: oldUser.email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const hasEarlyAccess = await prisma.userFeatures.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
if (hasEarlyAccess === 0) {
|
||||
await prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
return tx.userFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason: 'Early access user',
|
||||
activated: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
version: 1,
|
||||
},
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { UserType } from '../../modules/users';
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
export class UnamedAccount1703756315970 {
|
||||
// do the migration
|
||||
static async up(db: PrismaService) {
|
||||
await db.$transaction(async tx => {
|
||||
// only find users with empty names
|
||||
const users = await db.$queryRaw<
|
||||
UserType[]
|
||||
>`SELECT * FROM users WHERE name ~ E'^[\\s\\u2000-\\u200F]*$';`;
|
||||
console.log(
|
||||
`renaming ${users.map(({ email }) => email).join('|')} users`
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
users.map(({ id, email }) =>
|
||||
tx.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: email.split('@')[0],
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaService) {}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { WorkspaceBlobStorage } from '../../modules/storage';
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
export class WorkspaceBlobs1703828796699 {
|
||||
// do the migration
|
||||
static async up(db: PrismaService, injector: ModuleRef) {
|
||||
const blobStorage = injector.get(WorkspaceBlobStorage, { strict: false });
|
||||
let hasMore = true;
|
||||
let turn = 0;
|
||||
const eachTurnCount = 50;
|
||||
|
||||
while (hasMore) {
|
||||
const blobs = await db.blob.findMany({
|
||||
skip: turn * eachTurnCount,
|
||||
take: eachTurnCount,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
hasMore = blobs.length === eachTurnCount;
|
||||
turn += 1;
|
||||
|
||||
await Promise.all(
|
||||
blobs.map(async ({ workspaceId, hash, blob }) =>
|
||||
blobStorage.put(workspaceId, hash, blob)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaService) {
|
||||
// old data kept, no need to downgrade the migration
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Features } from '../../modules/features';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { upsertFeature } from './utils/user-features';
|
||||
|
||||
export class RefreshUserFeatures1704352562369 {
|
||||
// do the migration
|
||||
static async up(db: PrismaService) {
|
||||
// add early access v2 & copilot feature
|
||||
for (const feature of Features) {
|
||||
await upsertFeature(db, feature);
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down(_db: PrismaService) {}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import {
|
||||
CommonFeature,
|
||||
FeatureKind,
|
||||
FeatureType,
|
||||
} from '../../../modules/features';
|
||||
import { PrismaService } from '../../../prisma';
|
||||
|
||||
// upgrade features from lower version to higher version
|
||||
export async function upsertFeature(
|
||||
db: PrismaService,
|
||||
feature: CommonFeature
|
||||
): Promise<void> {
|
||||
const hasEqualOrGreaterVersion =
|
||||
(await db.features.count({
|
||||
where: {
|
||||
feature: feature.feature,
|
||||
version: {
|
||||
gte: feature.version,
|
||||
},
|
||||
},
|
||||
})) > 0;
|
||||
// will not update exists version
|
||||
if (!hasEqualOrGreaterVersion) {
|
||||
await db.features.create({
|
||||
data: {
|
||||
feature: feature.feature,
|
||||
type: feature.type,
|
||||
version: feature.version,
|
||||
configs: feature.configs as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateNewFeatureTable(prisma: PrismaService) {
|
||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||
for (const oldUser of waitingList) {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: oldUser.email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const hasEarlyAccess = await prisma.userFeatures.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
});
|
||||
if (hasEarlyAccess === 0) {
|
||||
await prisma.$transaction(async tx => {
|
||||
const latestFlag = await tx.userFeatures.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
feature: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
activated: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
if (latestFlag) {
|
||||
return latestFlag.id;
|
||||
} else {
|
||||
return tx.userFeatures
|
||||
.create({
|
||||
data: {
|
||||
reason: 'Early access user',
|
||||
activated: true,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: {
|
||||
feature: FeatureType.EarlyAccess,
|
||||
version: 1,
|
||||
},
|
||||
type: FeatureKind.Feature,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then(r => r.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { Snapshot, User, Workspace } from '@prisma/client';
|
||||
import type { Snapshot, Workspace } from '@prisma/client';
|
||||
|
||||
import { Flatten, Payload } from './types';
|
||||
|
||||
interface EventDefinitions {
|
||||
workspace: {
|
||||
deleted: Payload<Workspace['id']>;
|
||||
blob: {
|
||||
deleted: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
name: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
snapshot: {
|
||||
@@ -21,10 +15,6 @@ interface EventDefinitions {
|
||||
>;
|
||||
deleted: Payload<Pick<Snapshot, 'id' | 'workspaceId'>>;
|
||||
};
|
||||
|
||||
user: {
|
||||
deleted: Payload<User>;
|
||||
};
|
||||
}
|
||||
|
||||
export type EventKV = Flatten<EventDefinitions>;
|
||||
|
||||
@@ -5,6 +5,7 @@ startAutoMetrics();
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { static as staticMiddleware } from 'express';
|
||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import { AppModule } from './app';
|
||||
@@ -42,6 +43,10 @@ const config = app.get(Config);
|
||||
const host = config.node.prod ? '0.0.0.0' : 'localhost';
|
||||
const port = config.port ?? 3010;
|
||||
|
||||
if (!config.objectStorage.r2.enabled) {
|
||||
app.use('/assets', staticMiddleware(config.objectStorage.fs.path));
|
||||
}
|
||||
|
||||
if (config.redis.enabled) {
|
||||
const redisIoAdapter = new RedisIoAdapter(app);
|
||||
await redisIoAdapter.connectToRedis(
|
||||
|
||||
@@ -29,9 +29,7 @@ import {
|
||||
SpanExporter,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from '@opentelemetry/sdk-trace-node';
|
||||
import prismaInstrument from '@prisma/instrumentation';
|
||||
|
||||
const { PrismaInstrumentation } = prismaInstrument;
|
||||
import { PrismaInstrumentation } from '@prisma/instrumentation';
|
||||
|
||||
import { PrismaMetricProducer } from './prisma';
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export class AuthService {
|
||||
exp: now + this.config.auth.accessTokenExpiresIn,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
@@ -80,7 +80,7 @@ export class AuthService {
|
||||
iat: now,
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
@@ -100,7 +100,6 @@ export class AuthService {
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
aud: ['https://affine.pro'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const jwtEncode = async (
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: config.serverId,
|
||||
sub: user.id,
|
||||
aud: 'https://affine.pro',
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
|
||||
@@ -10,9 +10,6 @@ export class ServerConfigType {
|
||||
|
||||
@Field({ description: 'server flavor' })
|
||||
flavor!: string;
|
||||
|
||||
@Field({ description: 'server base url' })
|
||||
baseUrl!: string;
|
||||
}
|
||||
|
||||
export class ServerConfigResolver {
|
||||
@@ -23,7 +20,6 @@ export class ServerConfigResolver {
|
||||
return {
|
||||
version: AFFiNE.version,
|
||||
flavor: SERVER_FLAVOR,
|
||||
baseUrl: AFFiNE.baseUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Snapshot, Update } from '@prisma/client';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { defer, retry } from 'rxjs';
|
||||
@@ -80,7 +79,6 @@ export function isEmptyBuffer(buf: Buffer): boolean {
|
||||
}
|
||||
|
||||
const MAX_SEQ_NUM = 0x3fffffff; // u31
|
||||
const UPDATES_QUEUE_CACHE_KEY = 'doc:manager:updates';
|
||||
|
||||
/**
|
||||
* Since we can't directly save all client updates into database, in which way the database will overload,
|
||||
@@ -665,44 +663,26 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
count: number
|
||||
) {
|
||||
const result = await this.cache.mapIncrease(
|
||||
UPDATES_QUEUE_CACHE_KEY,
|
||||
`doc:manager:updates`,
|
||||
`${workspaceId}::${guid}`,
|
||||
count
|
||||
);
|
||||
|
||||
if (result <= 0) {
|
||||
await this.cache.mapDelete(
|
||||
UPDATES_QUEUE_CACHE_KEY,
|
||||
`doc:manager:updates`,
|
||||
`${workspaceId}::${guid}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAutoSquashCandidateFromCache() {
|
||||
const key = await this.cache.mapRandomKey(UPDATES_QUEUE_CACHE_KEY);
|
||||
const key = await this.cache.mapRandomKey('doc:manager:updates');
|
||||
|
||||
if (key) {
|
||||
const cachedCount = await this.cache.mapIncrease(
|
||||
UPDATES_QUEUE_CACHE_KEY,
|
||||
key,
|
||||
0
|
||||
);
|
||||
|
||||
if (cachedCount > 0) {
|
||||
const count = await this.cache.mapGet<number>('doc:manager:updates', key);
|
||||
if (typeof count === 'number' && count > 0) {
|
||||
const [workspaceId, id] = key.split('::');
|
||||
const count = await this.db.update.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
// FIXME(@forehalo): somehow the update count in cache is not accurate
|
||||
if (count === 0) {
|
||||
await this.cache.mapDelete(UPDATES_QUEUE_CACHE_KEY, key);
|
||||
|
||||
return null;
|
||||
}
|
||||
return { id, workspaceId };
|
||||
}
|
||||
}
|
||||
@@ -710,38 +690,22 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async doWithLock<T>(
|
||||
lockScope: string,
|
||||
lockResource: string,
|
||||
job: () => Promise<T>
|
||||
) {
|
||||
const lock = `lock:${lockScope}:${lockResource}`;
|
||||
private async doWithLock<T>(lock: string, job: () => Promise<T>) {
|
||||
const acquired = await this.cache.setnx(lock, 1, {
|
||||
ttl: 60 * 1000,
|
||||
});
|
||||
metrics.doc.counter('lock').add(1, { scope: lockScope });
|
||||
|
||||
if (!acquired) {
|
||||
metrics.doc.counter('lock_failed').add(1, { scope: lockScope });
|
||||
return;
|
||||
}
|
||||
metrics.doc.counter('lock_required').add(1, { scope: lockScope });
|
||||
|
||||
try {
|
||||
return await job();
|
||||
} finally {
|
||||
await this.cache
|
||||
.delete(lock)
|
||||
.then(() => {
|
||||
metrics.doc.counter('lock_released').add(1, { scope: lockScope });
|
||||
})
|
||||
.catch(e => {
|
||||
metrics.doc
|
||||
.counter('lock_release_failed')
|
||||
.add(1, { scope: lockScope });
|
||||
// safe, the lock will be expired when ttl ends
|
||||
this.logger.error(`Failed to release lock ${lock}`, e);
|
||||
});
|
||||
await this.cache.delete(lock).catch(e => {
|
||||
// safe, the lock will be expired when ttl ends
|
||||
this.logger.error(`Failed to release lock ${lock}`, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,8 +715,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
job: () => Promise<T>
|
||||
) {
|
||||
return this.doWithLock(
|
||||
'doc:manager:updates',
|
||||
`${workspaceId}::${guid}`,
|
||||
`doc:manager:updates-lock:${workspaceId}::${guid}`,
|
||||
job
|
||||
);
|
||||
}
|
||||
@@ -763,16 +726,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
job: () => Promise<T>
|
||||
) {
|
||||
return this.doWithLock(
|
||||
'doc:manager:snapshot',
|
||||
`${workspaceId}::${guid}`,
|
||||
`doc:manager:snapshot-lock:${workspaceId}::${guid}`,
|
||||
job
|
||||
);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async reportUpdatesQueueCount() {
|
||||
metrics.doc
|
||||
.gauge('updates_queue_count')
|
||||
.record(await this.db.update.count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,7 @@ class FeatureConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.Copilot };
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
if (this.config.feature !== FeatureType.Copilot) {
|
||||
throw new Error('Invalid feature config: type is not Copilot');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EarlyAccessFeatureConfig extends FeatureConfig {
|
||||
override config!: Feature & { feature: FeatureType.EarlyAccess };
|
||||
|
||||
constructor(data: any) {
|
||||
super(data);
|
||||
|
||||
@@ -40,18 +27,25 @@ export class EarlyAccessFeatureConfig extends FeatureConfig {
|
||||
throw new Error('Invalid feature config: type is not EarlyAccess');
|
||||
}
|
||||
}
|
||||
|
||||
checkWhiteList(email: string) {
|
||||
for (const domain in this.config.configs.whitelist) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const FeatureConfigMap = {
|
||||
[FeatureType.Copilot]: CopilotFeatureConfig,
|
||||
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
|
||||
};
|
||||
|
||||
export type FeatureConfigType<F extends FeatureType> = InstanceType<
|
||||
(typeof FeatureConfigMap)[F]
|
||||
>;
|
||||
|
||||
const FeatureCache = new Map<number, FeatureConfigType<FeatureType>>();
|
||||
const FeatureCache = new Map<
|
||||
number,
|
||||
InstanceType<(typeof FeatureConfigMap)[FeatureType]>
|
||||
>();
|
||||
|
||||
export async function getFeature(prisma: PrismaService, featureId: number) {
|
||||
const cachedQuota = FeatureCache.get(featureId);
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { EarlyAccessFeatureConfig } from './feature';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
const STAFF = ['@toeverything.info'];
|
||||
enum NewFeaturesKind {
|
||||
EarlyAccess,
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FeatureManagementService {
|
||||
export class FeatureManagementService implements OnModuleInit {
|
||||
protected logger = new Logger(FeatureManagementService.name);
|
||||
|
||||
private earlyAccessFeature?: EarlyAccessFeatureConfig;
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
async onModuleInit() {
|
||||
this.earlyAccessFeature = await this.feature.getFeature(
|
||||
FeatureType.EarlyAccess
|
||||
);
|
||||
}
|
||||
|
||||
// ======== Admin ========
|
||||
|
||||
// todo(@darkskygit): replace this with abac
|
||||
isStaff(email: string) {
|
||||
for (const domain of STAFF) {
|
||||
if (email.endsWith(domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return this.earlyAccessFeature?.checkWhiteList(email) ?? false;
|
||||
}
|
||||
|
||||
// ======== Early Access ========
|
||||
@@ -35,7 +38,7 @@ export class FeatureManagementService {
|
||||
return this.feature.addUserFeature(
|
||||
userId,
|
||||
FeatureType.EarlyAccess,
|
||||
2,
|
||||
1,
|
||||
'Early access user'
|
||||
);
|
||||
}
|
||||
@@ -48,68 +51,39 @@ export class FeatureManagementService {
|
||||
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);
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
const canEarlyAccess = await this.feature
|
||||
.hasFeature(user.id, FeatureType.EarlyAccess)
|
||||
.catch(() => false);
|
||||
if (canEarlyAccess) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Outdated, switch to feature gates
|
||||
const oldCanEarlyAccess = await this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.then(x => !!x)
|
||||
.catch(() => false);
|
||||
if (oldCanEarlyAccess) {
|
||||
this.logger.warn(
|
||||
`User ${email} has early access in old table but not in new table`
|
||||
);
|
||||
}
|
||||
return oldCanEarlyAccess;
|
||||
}
|
||||
return false;
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { UserType } from '../users/types';
|
||||
import { WorkspaceType } from '../workspaces/types';
|
||||
import { FeatureConfigType, getFeature } from './feature';
|
||||
import { getFeature } from './feature';
|
||||
import { FeatureKind, FeatureType } from './types';
|
||||
|
||||
@Injectable()
|
||||
@@ -22,23 +21,14 @@ export class FeatureService {
|
||||
});
|
||||
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;
|
||||
}
|
||||
acc[feature.feature] = feature.version;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
}
|
||||
|
||||
async getFeature<F extends FeatureType>(
|
||||
feature: F
|
||||
): Promise<FeatureConfigType<F> | undefined> {
|
||||
async getFeature(feature: FeatureType) {
|
||||
const data = await this.prisma.features.findFirst({
|
||||
where: {
|
||||
feature,
|
||||
@@ -50,13 +40,11 @@ export class FeatureService {
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
return getFeature(this.prisma, data.id) as FeatureConfigType<F>;
|
||||
return getFeature(this.prisma, data.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ======== User Features ========
|
||||
|
||||
async addUserFeature(
|
||||
userId: string,
|
||||
feature: FeatureType,
|
||||
@@ -126,11 +114,6 @@ export class FeatureService {
|
||||
.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: {
|
||||
@@ -184,7 +167,7 @@ export class FeatureService {
|
||||
.then(users => users.map(user => user.user));
|
||||
}
|
||||
|
||||
async hasUserFeature(userId: string, feature: FeatureType) {
|
||||
async hasFeature(userId: string, feature: FeatureType) {
|
||||
return this.prisma.userFeatures
|
||||
.count({
|
||||
where: {
|
||||
@@ -198,145 +181,4 @@ export class FeatureService {
|
||||
})
|
||||
.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,8 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
import { featureCopilot } from './copilot';
|
||||
import { featureEarlyAccess } from './early-access';
|
||||
import { z } from 'zod';
|
||||
|
||||
/// ======== common schema ========
|
||||
|
||||
@@ -22,13 +20,30 @@ export type CommonFeature = z.infer<typeof commonFeatureSchema>;
|
||||
|
||||
/// ======== feature define ========
|
||||
|
||||
export enum FeatureType {
|
||||
EarlyAccess = 'early_access',
|
||||
}
|
||||
|
||||
function checkHostname(host: string) {
|
||||
try {
|
||||
return new URL(`https://${host}`).hostname === host;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const featureEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.EarlyAccess),
|
||||
configs: z.object({
|
||||
whitelist: z
|
||||
.string()
|
||||
.startsWith('@')
|
||||
.refine(domain => checkHostname(domain.slice(1)))
|
||||
.array(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const Features: Feature[] = [
|
||||
{
|
||||
feature: FeatureType.Copilot,
|
||||
type: FeatureKind.Feature,
|
||||
version: 1,
|
||||
configs: {},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
@@ -37,12 +52,6 @@ export const Features: Feature[] = [
|
||||
whitelist: ['@toeverything.info'],
|
||||
},
|
||||
},
|
||||
{
|
||||
feature: FeatureType.EarlyAccess,
|
||||
type: FeatureKind.Feature,
|
||||
version: 2,
|
||||
configs: {},
|
||||
},
|
||||
];
|
||||
|
||||
/// ======== schema infer ========
|
||||
@@ -51,8 +60,6 @@ export const FeatureSchema = commonFeatureSchema
|
||||
.extend({
|
||||
type: z.literal(FeatureKind.Feature),
|
||||
})
|
||||
.and(z.discriminatedUnion('feature', [featureCopilot, featureEarlyAccess]));
|
||||
.and(z.discriminatedUnion('feature', [featureEarlyAccess]));
|
||||
|
||||
export type Feature = z.infer<typeof FeatureSchema>;
|
||||
|
||||
export { FeatureType };
|
||||
@@ -1,11 +0,0 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum FeatureType {
|
||||
Copilot = 'copilot',
|
||||
EarlyAccess = 'early_access',
|
||||
}
|
||||
|
||||
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,8 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FeatureType } from './common';
|
||||
|
||||
export const featureEarlyAccess = z.object({
|
||||
feature: z.literal(FeatureType.EarlyAccess),
|
||||
configs: z.object({}),
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import { DocModule } from './doc';
|
||||
import { PaymentModule } from './payment';
|
||||
import { QuotaModule } from './quota';
|
||||
import { SelfHostedModule } from './self-hosted';
|
||||
import { StorageModule } from './storage';
|
||||
import { SyncModule } from './sync';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
@@ -28,8 +27,7 @@ switch (SERVER_FLAVOR) {
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
SyncModule,
|
||||
DocModule,
|
||||
StorageModule
|
||||
DocModule
|
||||
);
|
||||
break;
|
||||
case 'graphql':
|
||||
@@ -41,8 +39,7 @@ switch (SERVER_FLAVOR) {
|
||||
UsersModule,
|
||||
DocModule,
|
||||
PaymentModule,
|
||||
QuotaModule,
|
||||
StorageModule
|
||||
QuotaModule
|
||||
);
|
||||
break;
|
||||
case 'allinone':
|
||||
@@ -56,8 +53,7 @@ switch (SERVER_FLAVOR) {
|
||||
QuotaModule,
|
||||
SyncModule,
|
||||
DocModule,
|
||||
PaymentModule,
|
||||
StorageModule
|
||||
PaymentModule
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -680,7 +680,7 @@ export class SubscriptionService {
|
||||
user: User,
|
||||
couponType: CouponType
|
||||
): Promise<string | null> {
|
||||
const earlyAccess = await this.features.isEarlyAccessUser(user.email);
|
||||
const earlyAccess = await this.features.canEarlyAccess(user.email);
|
||||
if (earlyAccess) {
|
||||
try {
|
||||
const coupon = await this.stripe.coupons.retrieve(couponType);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { StorageModule } from '../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
import { QuotaService } from './service';
|
||||
import { QuotaManagementService } from './storage';
|
||||
@@ -12,7 +11,6 @@ import { QuotaManagementService } from './storage';
|
||||
* - quota statistics
|
||||
*/
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [PermissionService, QuotaService, QuotaManagementService],
|
||||
exports: [QuotaService, QuotaManagementService],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { Storage } from '@affine/storage';
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { StorageProvide } from '../../storage';
|
||||
import { PermissionService } from '../workspaces/permission';
|
||||
import { QuotaService } from './service';
|
||||
|
||||
@@ -9,7 +10,7 @@ export class QuotaManagementService {
|
||||
constructor(
|
||||
private readonly quota: QuotaService,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly storage: WorkspaceBlobStorage
|
||||
@Inject(StorageProvide) private readonly storage: Storage
|
||||
) {}
|
||||
|
||||
async getUserQuota(userId: string) {
|
||||
@@ -28,12 +29,7 @@ export class QuotaManagementService {
|
||||
// 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);
|
||||
return this.storage.blobsSize(workspaces);
|
||||
}
|
||||
|
||||
// get workspace's owner quota and total size of used
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import test from 'ava';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { ListObjectsMetadata } from '../providers';
|
||||
import { FsStorageProvider } from '../providers/fs';
|
||||
|
||||
const config = {
|
||||
path: join(process.cwd(), 'node_modules', '.cache/affine-test-storage'),
|
||||
};
|
||||
|
||||
function createProvider() {
|
||||
return new FsStorageProvider(
|
||||
config,
|
||||
'test' + Math.random().toString(16).substring(2, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function keys(list: ListObjectsMetadata[]) {
|
||||
return list.map(i => i.key);
|
||||
}
|
||||
|
||||
async function randomPut(
|
||||
provider: FsStorageProvider,
|
||||
prefix = ''
|
||||
): Promise<string> {
|
||||
const key = prefix + 'test-key-' + Math.random().toString(16).substring(2, 8);
|
||||
const body = Buffer.from(key);
|
||||
provider.put(key, body);
|
||||
return key;
|
||||
}
|
||||
|
||||
test.after.always(() => {
|
||||
fs.rm(config.path, { recursive: true });
|
||||
});
|
||||
|
||||
test('put & get', async t => {
|
||||
const provider = createProvider();
|
||||
const key = 'testKey';
|
||||
const body = Buffer.from('testBody');
|
||||
await provider.put(key, body);
|
||||
|
||||
const result = await provider.get(key);
|
||||
|
||||
t.deepEqual(await getStreamAsBuffer(result.body!), body);
|
||||
t.is(result.metadata?.contentLength, body.length);
|
||||
});
|
||||
|
||||
test('list - one level', async t => {
|
||||
const provider = createProvider();
|
||||
const list = await Promise.all(
|
||||
Array.from({ length: 100 }).map(() => randomPut(provider))
|
||||
);
|
||||
list.sort();
|
||||
// random order, use set
|
||||
const result = await provider.list();
|
||||
t.deepEqual(keys(result), list);
|
||||
|
||||
const result2 = await provider.list('test-key');
|
||||
t.deepEqual(keys(result2), list);
|
||||
|
||||
const result3 = await provider.list('testKey');
|
||||
t.is(result3.length, 0);
|
||||
});
|
||||
|
||||
test('list recursively', async t => {
|
||||
const provider = createProvider();
|
||||
|
||||
await Promise.all([
|
||||
Promise.all(Array.from({ length: 10 }).map(() => randomPut(provider))),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/'))
|
||||
),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/b/'))
|
||||
),
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }).map(() => randomPut(provider, 'a/b/t/'))
|
||||
),
|
||||
]);
|
||||
|
||||
const r1 = await provider.list();
|
||||
t.is(r1.length, 40);
|
||||
|
||||
// contains all `a/xxx` and `a/b/xxx` and `a/b/c/xxx`
|
||||
const r2 = await provider.list('a');
|
||||
t.is(r2.length, 30);
|
||||
|
||||
// contains only `a/b/xxx`
|
||||
const r3 = await provider.list('a/b');
|
||||
const r4 = await provider.list('a/b/');
|
||||
t.is(r3.length, 20);
|
||||
t.deepEqual(r3, r4);
|
||||
|
||||
// prefix is not ended with '/', it's open to all files and sub dirs
|
||||
// contains all `a/b/t/xxx` and `a/b/t{xxxx}`
|
||||
const r5 = await provider.list('a/b/t');
|
||||
|
||||
t.is(r5.length, 20);
|
||||
});
|
||||
|
||||
test.only('delete', async t => {
|
||||
const provider = createProvider();
|
||||
const key = 'testKey';
|
||||
const body = Buffer.from('testBody');
|
||||
await provider.put(key, body);
|
||||
|
||||
await provider.delete(key);
|
||||
|
||||
await t.throwsAsync(() => fs.access(join(config.path, provider.bucket, key)));
|
||||
});
|
||||
30
packages/backend/server/src/modules/storage/fs.ts
Normal file
30
packages/backend/server/src/modules/storage/fs.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { FileUpload } from '../../types';
|
||||
|
||||
@Injectable()
|
||||
export class FSService {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
async writeFile(key: string, file: FileUpload) {
|
||||
const dest = this.config.objectStorage.fs.path;
|
||||
const fileName = `${key}-${randomUUID()}`;
|
||||
const prefix = this.config.node.dev
|
||||
? `${this.config.https ? 'https' : 'http'}://${this.config.host}:${
|
||||
this.config.port
|
||||
}`
|
||||
: '';
|
||||
await mkdir(dest, { recursive: true });
|
||||
const destFile = join(dest, fileName);
|
||||
await pipeline(file.createReadStream(), createWriteStream(destFile));
|
||||
|
||||
return `${prefix}/assets/${fileName}`;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
|
||||
import { FSService } from './fs';
|
||||
import { S3 } from './s3';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [WorkspaceBlobStorage, AvatarStorage],
|
||||
exports: [WorkspaceBlobStorage, AvatarStorage],
|
||||
providers: [S3, StorageService, FSService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
export { AvatarStorage, WorkspaceBlobStorage };
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
createReadStream,
|
||||
Dirent,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, parse, resolve } from 'node:path';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { FsStorageConfig } from '../../../config/storage';
|
||||
import {
|
||||
BlobInputType,
|
||||
GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, toBuffer } from './utils';
|
||||
|
||||
function escapeKey(key: string): string {
|
||||
// avoid '../' and './' in key
|
||||
return key.replace(/\.?\.[/\\]/g, '%');
|
||||
}
|
||||
|
||||
export class FsStorageProvider implements StorageProvider {
|
||||
private readonly path: string;
|
||||
private readonly logger: Logger;
|
||||
|
||||
readonly type = 'fs';
|
||||
|
||||
constructor(
|
||||
config: FsStorageConfig,
|
||||
public readonly bucket: string
|
||||
) {
|
||||
this.path = resolve(config.path, bucket);
|
||||
this.ensureAvailability();
|
||||
|
||||
this.logger = new Logger(`${FsStorageProvider.name}:${bucket}`);
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: BlobInputType,
|
||||
metadata: PutObjectMetadata = {}
|
||||
): Promise<void> {
|
||||
key = escapeKey(key);
|
||||
const blob = await toBuffer(body);
|
||||
|
||||
// write object
|
||||
this.writeObject(key, blob);
|
||||
// write metadata
|
||||
await this.writeMetadata(key, blob, metadata);
|
||||
this.logger.verbose(`Object \`${key}\` put`);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<{
|
||||
body?: Readable;
|
||||
metadata?: GetObjectMetadata;
|
||||
}> {
|
||||
key = escapeKey(key);
|
||||
|
||||
try {
|
||||
const metadata = this.readMetadata(key);
|
||||
const stream = this.readObject(this.join(key));
|
||||
this.logger.verbose(`Read object \`${key}\``);
|
||||
return {
|
||||
body: stream,
|
||||
metadata,
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to read object \`${key}\``, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<ListObjectsMetadata[]> {
|
||||
// prefix cases:
|
||||
// - `undefined`: list all objects
|
||||
// - `a/b`: list objects under dir `a` with prefix `b`, `b` might be a dir under `a` as well.
|
||||
// - `a/b/` list objects under dir `a/b`
|
||||
|
||||
// read dir recursively and filter out '.metadata.json' files
|
||||
let dir = this.path;
|
||||
if (prefix) {
|
||||
prefix = escapeKey(prefix);
|
||||
const parts = prefix.split(/[/\\]/);
|
||||
// for prefix `a/b/c`, move `a/b` to dir and `c` to key prefix
|
||||
if (parts.length > 1) {
|
||||
dir = join(dir, ...parts.slice(0, -1));
|
||||
prefix = parts[parts.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
const results: ListObjectsMetadata[] = [];
|
||||
async function getFiles(dir: string, prefix?: string): Promise<void> {
|
||||
try {
|
||||
const entries: Dirent[] = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const res = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!prefix || entry.name.startsWith(prefix)) {
|
||||
await getFiles(res);
|
||||
}
|
||||
} else if (
|
||||
(!prefix || entry.name.startsWith(prefix)) &&
|
||||
!entry.name.endsWith('.metadata.json')
|
||||
) {
|
||||
const stat = statSync(res);
|
||||
results.push({
|
||||
key: res,
|
||||
lastModified: stat.mtime,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// failed to read dir, stop recursion
|
||||
}
|
||||
}
|
||||
|
||||
await getFiles(dir, prefix);
|
||||
|
||||
// trim path with `this.path` prefix
|
||||
results.forEach(r => (r.key = r.key.slice(this.path.length + 1)));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
key = escapeKey(key);
|
||||
|
||||
try {
|
||||
rmSync(this.join(key), { force: true });
|
||||
rmSync(this.join(`${key}.metadata.json`), { force: true });
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to delete object \`${key}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.verbose(`Object \`${key}\` deleted`);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
ensureAvailability() {
|
||||
// check stats
|
||||
const stats = statSync(this.path, {
|
||||
throwIfNoEntry: false,
|
||||
});
|
||||
|
||||
// not existing, create it
|
||||
if (!stats) {
|
||||
try {
|
||||
mkdirSync(this.path, { recursive: true });
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to create target directory for fs storage provider: ${this.path}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (stats.isDirectory()) {
|
||||
// the target directory has already existed, check if it is readable & writable
|
||||
try {
|
||||
accessSync(this.path, constants.W_OK | constants.R_OK);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`The target directory for fs storage provider has already existed, but it is not readable & writable: ${this.path}`,
|
||||
{
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if (stats.isFile()) {
|
||||
throw new Error(
|
||||
`The target directory for fs storage provider is a file: ${this.path}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private join(...paths: string[]) {
|
||||
return join(this.path, ...paths);
|
||||
}
|
||||
|
||||
private readObject(file: string): Readable | undefined {
|
||||
const state = statSync(file, { throwIfNoEntry: false });
|
||||
|
||||
if (state?.isFile()) {
|
||||
return createReadStream(file);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private writeObject(key: string, blob: Buffer) {
|
||||
const path = this.join(key);
|
||||
mkdirSync(parse(path).dir, { recursive: true });
|
||||
writeFileSync(path, blob);
|
||||
}
|
||||
|
||||
private async writeMetadata(
|
||||
key: string,
|
||||
blob: Buffer,
|
||||
raw: PutObjectMetadata
|
||||
) {
|
||||
try {
|
||||
const metadata = await autoMetadata(blob, raw);
|
||||
|
||||
if (raw.checksumCRC32 && metadata.checksumCRC32 !== raw.checksumCRC32) {
|
||||
throw new Error(
|
||||
'The checksum of the uploaded file is not matched with the one you provide, the file may be corrupted and the uploading will not be processed.'
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
this.join(`${key}.metadata.json`),
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to write metadata of object \`${key}\``, e);
|
||||
}
|
||||
}
|
||||
|
||||
private readMetadata(key: string): GetObjectMetadata | undefined {
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
readFileSync(this.join(`${key}.metadata.json`), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
...raw,
|
||||
lastModified: new Date(raw.lastModified),
|
||||
expires: raw.expires ? new Date(raw.expires) : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to read metadata of object \`${key}\``, e);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { AFFiNEStorageConfig, Storages } from '../../../config/storage';
|
||||
import { FsStorageProvider } from './fs';
|
||||
import type { StorageProvider } from './provider';
|
||||
import { R2StorageProvider } from './r2';
|
||||
import { S3StorageProvider } from './s3';
|
||||
|
||||
export function createStorageProvider(
|
||||
config: AFFiNEStorageConfig,
|
||||
storage: Storages
|
||||
): StorageProvider {
|
||||
const storageConfig = config.storages[storage];
|
||||
const providerConfig = config.providers[storageConfig.provider] as any;
|
||||
if (!providerConfig) {
|
||||
throw new Error(
|
||||
`Failed to create ${storageConfig.provider} storage, configuration not correctly set`
|
||||
);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 's3') {
|
||||
return new S3StorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 'r2') {
|
||||
return new R2StorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
if (storageConfig.provider === 'fs') {
|
||||
return new FsStorageProvider(providerConfig, storageConfig.bucket);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown storage provider type: ${storageConfig.provider}`);
|
||||
}
|
||||
|
||||
export type * from './provider';
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
import { StorageProviderType } from '../../../config';
|
||||
|
||||
export interface GetObjectMetadata {
|
||||
/**
|
||||
* @default 'application/octet-stream'
|
||||
*/
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
lastModified: Date;
|
||||
checksumCRC32?: string;
|
||||
}
|
||||
|
||||
export interface PutObjectMetadata {
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
checksumCRC32?: string;
|
||||
}
|
||||
|
||||
export interface ListObjectsMetadata {
|
||||
key: string;
|
||||
lastModified: Date;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type BlobInputType = Buffer | Readable | string;
|
||||
export type BlobOutputType = Readable;
|
||||
|
||||
export interface StorageProvider {
|
||||
readonly type: StorageProviderType;
|
||||
put(
|
||||
key: string,
|
||||
body: BlobInputType,
|
||||
metadata?: PutObjectMetadata
|
||||
): Promise<void>;
|
||||
get(
|
||||
key: string
|
||||
): Promise<{ body?: BlobOutputType; metadata?: GetObjectMetadata }>;
|
||||
list(prefix?: string): Promise<ListObjectsMetadata[]>;
|
||||
delete(key: string): Promise<void>;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { R2StorageConfig } from '../../../config/storage';
|
||||
import { S3StorageProvider } from './s3';
|
||||
|
||||
export class R2StorageProvider extends S3StorageProvider {
|
||||
override readonly type = 'r2' as any /* cast 'r2' to 's3' */;
|
||||
|
||||
constructor(config: R2StorageConfig, bucket: string) {
|
||||
super(
|
||||
{
|
||||
...config,
|
||||
forcePathStyle: true,
|
||||
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
|
||||
},
|
||||
bucket
|
||||
);
|
||||
this.logger = new Logger(`${R2StorageProvider.name}:${bucket}`);
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
NoSuchKey,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
import { S3StorageConfig } from '../../../config/storage';
|
||||
import {
|
||||
BlobInputType,
|
||||
GetObjectMetadata,
|
||||
ListObjectsMetadata,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, toBuffer } from './utils';
|
||||
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
protected logger: Logger;
|
||||
protected client: S3Client;
|
||||
|
||||
readonly type = 's3';
|
||||
|
||||
constructor(
|
||||
config: S3StorageConfig,
|
||||
public readonly bucket: string
|
||||
) {
|
||||
this.client = new S3Client({ region: 'auto', ...config });
|
||||
this.logger = new Logger(`${S3StorageProvider.name}:${bucket}`);
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: BlobInputType,
|
||||
metadata: PutObjectMetadata = {}
|
||||
): Promise<void> {
|
||||
const blob = await toBuffer(body);
|
||||
|
||||
metadata = await autoMetadata(blob, metadata);
|
||||
|
||||
try {
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: blob,
|
||||
|
||||
// metadata
|
||||
ContentType: metadata.contentType,
|
||||
ContentLength: metadata.contentLength,
|
||||
// TODO: Cloudflare doesn't support CRC32, use md5 instead later.
|
||||
// ChecksumCRC32: metadata.checksumCRC32,
|
||||
})
|
||||
);
|
||||
|
||||
this.logger.verbose(`Object \`${key}\` put`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to put object \`${key}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<{
|
||||
body?: Readable;
|
||||
metadata?: GetObjectMetadata;
|
||||
}> {
|
||||
try {
|
||||
const obj = await this.client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
if (!obj.Body) {
|
||||
this.logger.verbose(`Object \`${key}\` not found`);
|
||||
return {};
|
||||
}
|
||||
|
||||
this.logger.verbose(`Read object \`${key}\``);
|
||||
return {
|
||||
// @ts-expect-errors ignore browser response type `Blob`
|
||||
body: obj.Body,
|
||||
metadata: {
|
||||
// always set when putting object
|
||||
contentType: obj.ContentType!,
|
||||
contentLength: obj.ContentLength!,
|
||||
lastModified: obj.LastModified!,
|
||||
checksumCRC32: obj.ChecksumCRC32,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
// 404
|
||||
if (e instanceof NoSuchKey) {
|
||||
this.logger.verbose(`Object \`${key}\` not found`);
|
||||
return {};
|
||||
} else {
|
||||
throw new Error(`Failed to read object \`${key}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<ListObjectsMetadata[]> {
|
||||
// continuationToken should be `string | undefined`,
|
||||
// but TypeScript will fail on type infer in the code below.
|
||||
// Seems to be a bug in TypeScript
|
||||
let continuationToken: any = undefined;
|
||||
let hasMore = true;
|
||||
let result: ListObjectsMetadata[] = [];
|
||||
|
||||
try {
|
||||
while (hasMore) {
|
||||
const listResult = await this.client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
})
|
||||
);
|
||||
|
||||
if (listResult.Contents?.length) {
|
||||
result = result.concat(
|
||||
listResult.Contents.map(r => ({
|
||||
key: r.Key!,
|
||||
lastModified: r.LastModified!,
|
||||
size: r.Size!,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// has more items not listed
|
||||
hasMore = !!listResult.IsTruncated;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
|
||||
this.logger.verbose(
|
||||
`List ${result.length} objects with prefix \`${prefix}\``
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to list objects with prefix \`${prefix}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to delete object \`${key}\``, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { BlobInputType, PutObjectMetadata } from './provider';
|
||||
|
||||
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
|
||||
return input instanceof Readable
|
||||
? await getStreamAsBuffer(input)
|
||||
: input instanceof Buffer
|
||||
? input
|
||||
: Buffer.from(input);
|
||||
}
|
||||
|
||||
export async function autoMetadata(
|
||||
blob: Buffer,
|
||||
raw: PutObjectMetadata
|
||||
): Promise<PutObjectMetadata> {
|
||||
const metadata = {
|
||||
...raw,
|
||||
};
|
||||
try {
|
||||
// length
|
||||
if (!metadata.contentLength) {
|
||||
metadata.contentLength = blob.length;
|
||||
}
|
||||
|
||||
// checksum
|
||||
if (!metadata.checksumCRC32) {
|
||||
metadata.checksumCRC32 = crc32(blob).toString(16);
|
||||
}
|
||||
|
||||
// mime type
|
||||
if (!metadata.contentType) {
|
||||
try {
|
||||
const typeResult = await fileTypeFromBuffer(blob);
|
||||
metadata.contentType = typeResult?.mime ?? 'application/octet-stream';
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
} catch (e) {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
22
packages/backend/server/src/modules/storage/s3.ts
Normal file
22
packages/backend/server/src/modules/storage/s3.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { S3Client } from '@aws-sdk/client-s3';
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
export const S3_SERVICE = Symbol('S3_SERVICE');
|
||||
|
||||
export const S3: FactoryProvider<S3Client> = {
|
||||
provide: S3_SERVICE,
|
||||
useFactory: (config: Config) => {
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: `https://${config.objectStorage.r2.accountId}.r2.cloudflarestorage.com`,
|
||||
credentials: {
|
||||
accessKeyId: config.objectStorage.r2.accessKeyId,
|
||||
secretAccessKey: config.objectStorage.r2.secretAccessKey,
|
||||
},
|
||||
});
|
||||
return s3;
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - no types
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { FileUpload } from '../../types';
|
||||
import { FSService } from './fs';
|
||||
import { S3_SERVICE } from './s3';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
constructor(
|
||||
@Inject(S3_SERVICE) private readonly s3: S3Client,
|
||||
private readonly fs: FSService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
async uploadFile(key: string, file: FileUpload) {
|
||||
if (this.config.objectStorage.r2.enabled) {
|
||||
const readableFile = file.createReadStream();
|
||||
const fileBuffer = await getStreamAsBuffer(readableFile);
|
||||
const mime = (await fileTypeFromBuffer(fileBuffer))?.mime;
|
||||
const crc32Value = crc32(fileBuffer);
|
||||
const keyWithCrc32 = `${crc32Value}-${key}`;
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Body: fileBuffer,
|
||||
Bucket: this.config.objectStorage.r2.bucket,
|
||||
Key: keyWithCrc32,
|
||||
ContentLength: fileBuffer.length,
|
||||
ContentType: mime,
|
||||
})
|
||||
);
|
||||
return `https://avatar.affineassets.com/${keyWithCrc32}`;
|
||||
} else {
|
||||
return this.fs.writeFile(key, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { AFFiNEStorageConfig, Config } from '../../../config';
|
||||
import { type EventPayload, OnEvent } from '../../../event';
|
||||
import {
|
||||
BlobInputType,
|
||||
createStorageProvider,
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from '../providers';
|
||||
|
||||
@Injectable()
|
||||
export class AvatarStorage {
|
||||
public readonly provider: StorageProvider;
|
||||
private readonly storageConfig: AFFiNEStorageConfig['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,114 +0,0 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { Storage } from '@affine/storage';
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../../config';
|
||||
import { EventEmitter, type EventPayload, OnEvent } from '../../../event';
|
||||
import { OctoBaseStorageModule } from '../../../storage';
|
||||
import {
|
||||
BlobInputType,
|
||||
createStorageProvider,
|
||||
StorageProvider,
|
||||
} from '../providers';
|
||||
import { toBuffer } from '../providers/utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceBlobStorage implements OnModuleInit {
|
||||
public readonly provider: StorageProvider;
|
||||
|
||||
/**
|
||||
* @deprecated for backwards compatibility, need to be removed in next stable release
|
||||
*/
|
||||
private octobase: Storage | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly event: EventEmitter,
|
||||
private readonly config: Config
|
||||
) {
|
||||
this.provider = createStorageProvider(this.config.storage, 'blob');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
if (!this.config.node.test) {
|
||||
this.octobase = await OctoBaseStorageModule.Storage.connect(
|
||||
this.config.db.url
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async put(workspaceId: string, key: string, blob: BlobInputType) {
|
||||
const buf = await toBuffer(blob);
|
||||
await this.provider.put(`${workspaceId}/${key}`, buf);
|
||||
if (this.octobase) {
|
||||
await this.octobase.uploadBlob(workspaceId, buf);
|
||||
}
|
||||
}
|
||||
|
||||
async get(workspaceId: string, key: string) {
|
||||
const result = await this.provider.get(`${workspaceId}/${key}`);
|
||||
if (!result.body && this.octobase) {
|
||||
const blob = await this.octobase.getBlob(workspaceId, key);
|
||||
|
||||
if (!blob) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
body: Readable.from(blob.data),
|
||||
metadata: {
|
||||
contentType: blob.contentType,
|
||||
contentLength: blob.size,
|
||||
lastModified: new Date(blob.lastModified),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,12 @@ import { Module } from '@nestjs/common';
|
||||
import { FeatureModule } from '../features';
|
||||
import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserManagementResolver } from './management';
|
||||
import { UserResolver } from './resolver';
|
||||
import { UsersService } from './users';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, FeatureModule, QuotaModule],
|
||||
providers: [UserResolver, UserManagementResolver, UsersService],
|
||||
controllers: [UserAvatarController],
|
||||
providers: [UserResolver, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -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 '../../throttler';
|
||||
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,6 +1,12 @@
|
||||
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
Int,
|
||||
Mutation,
|
||||
Query,
|
||||
@@ -11,21 +17,15 @@ import type { User } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import { EventEmitter } from '../../event';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { FeatureManagementService } from '../features';
|
||||
import { QuotaService } from '../quota';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import {
|
||||
DeleteAccount,
|
||||
RemoveAvatar,
|
||||
UserOrLimitedUser,
|
||||
UserQuotaType,
|
||||
UserType,
|
||||
} from './types';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { DeleteAccount, RemoveAvatar, UserQuotaType, UserType } from './types';
|
||||
import { UsersService } from './users';
|
||||
|
||||
/**
|
||||
@@ -37,12 +37,12 @@ import { UsersService } from './users';
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly storage: StorageService,
|
||||
private readonly users: UsersService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly event: EventEmitter
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
@Throttle({
|
||||
@@ -83,17 +83,14 @@ export class UserResolver {
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Query(() => UserOrLimitedUser, {
|
||||
@Query(() => UserType, {
|
||||
name: 'user',
|
||||
description: 'Get user by email',
|
||||
nullable: true,
|
||||
})
|
||||
@Public()
|
||||
async user(
|
||||
@CurrentUser() currentUser?: UserType,
|
||||
@Args('email') email?: string
|
||||
) {
|
||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||
async user(@Args('email') email: string) {
|
||||
if (!(await this.feature.canEarlyAccess(email))) {
|
||||
return new GraphQLError(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
|
||||
{
|
||||
@@ -104,19 +101,13 @@ export class UserResolver {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserByEmail(email);
|
||||
if (currentUser) return user;
|
||||
|
||||
// return empty response when user not exists
|
||||
if (!user) return null;
|
||||
|
||||
// only return limited info when not logged in
|
||||
return {
|
||||
email: user?.email,
|
||||
hasPassword: !!user?.password,
|
||||
};
|
||||
if (user?.password) {
|
||||
const userResponse: UserType = user;
|
||||
userResponse.hasPassword = true;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Throttle({ default: { limit: 10, ttl: 60 } })
|
||||
@@ -156,20 +147,10 @@ export class UserResolver {
|
||||
if (!user) {
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
|
||||
const link = await this.storage.put(
|
||||
`${user.id}-avatar`,
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
);
|
||||
|
||||
const url = await this.storage.uploadFile(`${user.id}-avatar`, avatar);
|
||||
return this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
avatarUrl: link,
|
||||
},
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,8 +183,70 @@ export class UserResolver {
|
||||
})
|
||||
@Mutation(() => DeleteAccount)
|
||||
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
|
||||
const deletedUser = await this.users.deleteUser(user.id);
|
||||
this.event.emit('user.deleted', deletedUser);
|
||||
await this.users.deleteUser(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@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,4 +1,4 @@
|
||||
import { createUnionType, Field, Float, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, Float, ID, ObjectType } from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
@ObjectType('UserQuotaHumanReadable')
|
||||
@@ -67,29 +67,6 @@ export class UserType implements Partial<User> {
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class LimitedUserType implements Partial<User> {
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export const UserOrLimitedUser = createUnionType({
|
||||
name: 'UserOrLimitedUser',
|
||||
types: () => [UserType, LimitedUserType] as const,
|
||||
resolveType(value) {
|
||||
if (value.id) {
|
||||
return UserType;
|
||||
}
|
||||
return LimitedUserType;
|
||||
},
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class DeleteAccount {
|
||||
@Field()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Storage } from '@affine/storage';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Logger,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Res,
|
||||
@@ -11,19 +12,18 @@ import type { Response } from 'express';
|
||||
|
||||
import { CallTimer } from '../../metrics';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { StorageProvide } from '../../storage';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { Auth, CurrentUser, Publicable } from '../auth';
|
||||
import { DocHistoryManager, DocManager } from '../doc';
|
||||
import { WorkspaceBlobStorage } from '../storage';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService, PublicPageMode } from './permission';
|
||||
import { Permission } from './types';
|
||||
|
||||
@Controller('/api/workspaces')
|
||||
export class WorkspacesController {
|
||||
logger = new Logger(WorkspacesController.name);
|
||||
constructor(
|
||||
private readonly storage: WorkspaceBlobStorage,
|
||||
@Inject(StorageProvide) private readonly storage: Storage,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly docManager: DocManager,
|
||||
private readonly historyManager: DocHistoryManager,
|
||||
@@ -40,25 +40,19 @@ export class WorkspacesController {
|
||||
@Param('name') name: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const { body, metadata } = await this.storage.get(workspaceId, name);
|
||||
const blob = await this.storage.getBlob(workspaceId, name);
|
||||
|
||||
if (!body) {
|
||||
if (!blob) {
|
||||
throw new NotFoundException(
|
||||
`Blob not found in workspace ${workspaceId}: ${name}`
|
||||
);
|
||||
}
|
||||
|
||||
// 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);
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
}
|
||||
res.setHeader('content-type', blob.contentType);
|
||||
res.setHeader('last-modified', blob.lastModified);
|
||||
res.setHeader('content-length', blob.size);
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
||||
body.pipe(res);
|
||||
res.send(blob.data);
|
||||
}
|
||||
|
||||
// get doc binary
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -12,13 +11,13 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import type { SnapshotHistory } from '@prisma/client';
|
||||
|
||||
import { CloudThrottlerGuard } from '../../../throttler';
|
||||
import { DocID } from '../../../utils/doc';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocHistoryManager } from '../../doc/history';
|
||||
import { UserType } from '../../users';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Permission, WorkspaceType } from '../types';
|
||||
import { DocID } from '../../utils/doc';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { DocHistoryManager } from '../doc/history';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceType } from './resolver';
|
||||
import { Permission } from './types';
|
||||
|
||||
@ObjectType()
|
||||
class DocHistoryType implements Partial<SnapshotHistory> {
|
||||
@@ -32,7 +31,6 @@ class DocHistoryType implements Partial<SnapshotHistory> {
|
||||
timestamp!: Date;
|
||||
}
|
||||
|
||||
@UseGuards(CloudThrottlerGuard)
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class DocHistoryResolver {
|
||||
constructor(
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user