feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions
+3
View File
@@ -1,2 +1,5 @@
DATABASE_URL="postgresql://affine@localhost:5432/affine"
NEXTAUTH_URL="http://localhost:8080"
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
OAUTH_EMAIL_LOGIN=""
OAUTH_EMAIL_PASSWORD=""
+44
View File
@@ -0,0 +1,44 @@
The AFFiNE Enterprise Edition (EE) license (the “EE License”)
Copyright (c) 2022-present TOEVERYTHING PTE. LTD. and its affiliates.
With regard to the AFFiNE Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the AFFiNE Subscription Terms of Service, available
at https://affine.pro/terms/#subscription (the “EE Terms”), or other
agreement governing the use of the Software, as agreed by you and AFFiNE,
and otherwise have a valid AFFiNE Enterprise Edition subscription for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that AFFiNE
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid AFFiNE Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that AFFiNE and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This EE License applies only to the part of this Software that is not
distributed as part of AFFiNE Community Edition (CE). Any part of this Software
distributed as part of AFFiNE CE or is served client-side as an image, font,
cascading stylesheet (CSS), file which produces or is compiled, arranged,
augmented, or combined into client-side JavaScript, in whole or in part, is
copyrighted under the MPL2.0 license. The full text of this EE License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the AFFiNE Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "blobs" DROP CONSTRAINT "blobs_workspace_id_fkey";
-- DropForeignKey
ALTER TABLE "docs" DROP CONSTRAINT "docs_workspace_id_fkey";
-- DropForeignKey
ALTER TABLE "optimized_blobs" DROP CONSTRAINT "optimized_blobs_workspace_id_fkey";
@@ -0,0 +1,12 @@
/*
Warnings:
- A unique constraint covering the columns `[workspace_id,sub_page_id,entity_id]` on the table `user_workspace_permissions` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "user_workspace_permissions" ADD COLUMN "sub_page_id" VARCHAR,
ALTER COLUMN "entity_id" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "user_workspace_permissions_workspace_id_sub_page_id_entity__key" ON "user_workspace_permissions"("workspace_id", "sub_page_id", "entity_id");
@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `image` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "users" DROP COLUMN "image",
ADD COLUMN "avatar_url" VARCHAR;
@@ -0,0 +1,18 @@
/*
Warnings:
- The primary key for the `blobs` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `optimized_blobs` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- AlterTable
ALTER TABLE "blobs" DROP CONSTRAINT "blobs_pkey",
ADD COLUMN "id" SERIAL NOT NULL,
ALTER COLUMN "length" SET DATA TYPE BIGINT,
ADD CONSTRAINT "blobs_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "optimized_blobs" DROP CONSTRAINT "optimized_blobs_pkey",
ADD COLUMN "id" SERIAL NOT NULL,
ALTER COLUMN "length" SET DATA TYPE BIGINT,
ADD CONSTRAINT "optimized_blobs_pkey" PRIMARY KEY ("id");
@@ -0,0 +1,42 @@
/*
Warnings:
- You are about to drop the `docs` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
DROP TABLE "docs";
-- CreateTable
CREATE TABLE "snapshots" (
"guid" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"blob" BYTEA NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "snapshots_pkey" PRIMARY KEY ("guid")
);
-- CreateTable
CREATE TABLE "updates" (
"object_id" VARCHAR NOT NULL,
"guid" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"blob" BYTEA NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "updates_pkey" PRIMARY KEY ("object_id")
);
-- CreateIndex
CREATE INDEX "snapshots_workspace_id_idx" ON "snapshots"("workspace_id");
-- CreateIndex
CREATE INDEX "updates_guid_workspace_id_idx" ON "updates"("guid", "workspace_id");
-- AddForeignKey
ALTER TABLE "snapshots" ADD CONSTRAINT "snapshots_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "updates" ADD CONSTRAINT "updates_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
/*
Warnings:
- The primary key for the `snapshots` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- DropIndex
DROP INDEX "snapshots_workspace_id_idx";
-- AlterTable
ALTER TABLE "snapshots" DROP CONSTRAINT "snapshots_pkey",
ADD CONSTRAINT "snapshots_pkey" PRIMARY KEY ("guid", "workspace_id");
@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "snapshots" DROP CONSTRAINT "snapshots_workspace_id_fkey";
-- DropForeignKey
ALTER TABLE "updates" DROP CONSTRAINT "updates_workspace_id_fkey";
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "new_features_waiting_list" (
"id" VARCHAR NOT NULL,
"email" TEXT NOT NULL,
"type" SMALLINT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "new_features_waiting_list_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "new_features_waiting_list_email_key" ON "new_features_waiting_list"("email");
@@ -0,0 +1,8 @@
/*
Warnings:
- Made the column `email` on table `users` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "email" SET NOT NULL;
+34 -3
View File
@@ -9,6 +9,7 @@
},
"scripts": {
"build": "tsc",
"start": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/index.ts",
"dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
"test:watch": "yarn exec ts-node-esm ./scripts/run-test.ts all --watch",
@@ -18,30 +19,55 @@
"dependencies": {
"@apollo/server": "^4.9.2",
"@auth/prisma-adapter": "^1.0.1",
"@aws-sdk/client-s3": "^3.398.0",
"@aws-sdk/client-s3": "^3.400.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@nestjs/apollo": "^12.0.7",
"@nestjs/common": "^10.2.1",
"@nestjs/core": "^10.2.1",
"@nestjs/graphql": "^12.0.8",
"@nestjs/platform-express": "^10.2.1",
"@nestjs/platform-express": "^10.1.3",
"@nestjs/platform-socket.io": "^10.0.5",
"@nestjs/websockets": "^10.0.5",
"@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.2.3",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/instrumentation": "^0.41.1",
"@opentelemetry/instrumentation-graphql": "^0.35.0",
"@opentelemetry/instrumentation-http": "^0.41.1",
"@opentelemetry/instrumentation-ioredis": "^0.35.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.0",
"@opentelemetry/instrumentation-socket.io": "^0.34.0",
"@opentelemetry/sdk-metrics": "^1.15.1",
"@opentelemetry/sdk-node": "^0.41.1",
"@opentelemetry/sdk-trace-node": "^1.15.1",
"@prisma/client": "^5.2.0",
"@prisma/instrumentation": "^5.0.0",
"@socket.io/redis-adapter": "^8.2.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"file-type": "^18.5.0",
"get-stream": "^7.0.1",
"graphql": "^16.8.0",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^16.0.2",
"ioredis": "^5.3.2",
"lodash-es": "^4.17.21",
"next-auth": "4.22.5",
"nodemailer": "^6.9.4",
"on-headers": "^1.0.2",
"parse-duration": "^1.1.0",
"pretty-time": "^1.1.0",
"prisma": "^5.1.1",
"prom-client": "^14.2.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"semver": "^7.5.4"
"semver": "^7.5.4",
"socket.io": "^4.7.1",
"ws": "^8.13.0",
"yjs": "^13.6.6"
},
"devDependencies": {
"@affine/storage": "workspace:*",
@@ -52,9 +78,14 @@
"@types/lodash-es": "^4.17.8",
"@types/node": "^18.17.11",
"@types/nodemailer": "^6.4.9",
"@types/on-headers": "^1.0.0",
"@types/pretty-time": "^1.1.2",
"@types/sinon": "^10.0.15",
"@types/supertest": "^2.0.12",
"@types/ws": "^8.5.5",
"c8": "^8.0.1",
"nodemon": "^3.0.1",
"sinon": "^15.2.0",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
+48 -30
View File
@@ -1,6 +1,7 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["metrics", "tracing"]
}
datasource db {
@@ -9,13 +10,10 @@ datasource db {
}
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
blobs Blob[]
docs Doc[]
optimizedBlobs OptimizedBlob[]
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
users UserWorkspacePermission[]
@@map("workspaces")
}
@@ -23,25 +21,27 @@ model Workspace {
model UserWorkspacePermission {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("entity_id") @db.VarChar
subPageId String? @map("sub_page_id") @db.VarChar
userId String? @map("entity_id") @db.VarChar
/// Read/Write/Admin/Owner
type Int @db.SmallInt
/// Whether the permission invitation is accepted by the user
accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, subPageId, userId])
@@map("user_workspace_permissions")
}
model User {
id String @id @default(uuid()) @db.VarChar
name String
email String? @unique
email String @unique
emailVerified DateTime? @map("email_verified")
// image field is for the next-auth
avatarUrl String? @map("image") @db.VarChar
avatarUrl String? @map("avatar_url") @db.VarChar
accounts Account[]
sessions Session[]
workspaces UserWorkspacePermission[]
@@ -92,42 +92,60 @@ model VerificationToken {
}
model Blob {
hash String @id @default(uuid()) @db.VarChar
id Int @id @default(autoincrement()) @db.Integer
hash String @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
length Int
length BigInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, hash])
@@map("blobs")
}
model OptimizedBlob {
hash String @id @default(uuid()) @db.VarChar
id Int @id @default(autoincrement()) @db.Integer
hash String @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
params String @db.VarChar
blob Bytes @db.ByteA
length Int
length BigInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, hash, params])
@@map("optimized_blobs")
}
model Doc {
id Int @id @default(autoincrement()) @db.Integer
workspaceId String @map("workspace_id") @db.VarChar
guid String @db.VarChar
is_workspace Boolean @default(true) @db.Boolean
blob Bytes @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// the latest snapshot of each doc that we've seen
// Snapshot + Updates are the latest state of the doc
model Snapshot {
id String @default(uuid()) @map("guid") @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([workspaceId, guid])
@@map("docs")
@@id([id, workspaceId])
@@map("snapshots")
}
// backup during other update operation queue downtime
model Update {
objectId String @id @default(uuid()) @map("object_id") @db.VarChar
id String @map("guid") @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@index([id, workspaceId])
@@map("updates")
}
model NewFeaturesWaitingList {
id String @id @default(uuid()) @db.VarChar
email String @unique
type Int @db.SmallInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("new_features_waiting_list")
}
+5 -1
View File
@@ -1,10 +1,14 @@
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
import { hash } from '@node-rs/argon2';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.user.create({
data: userA,
data: {
...userA,
password: await hash(userA.password),
},
});
}
+1
View File
@@ -24,6 +24,7 @@ const env = {
PATH: process.env.PATH,
NODE_ENV: 'test',
DATABASE_URL: process.env.DATABASE_URL,
NODE_NO_WARNINGS: '1',
};
if (process.argv[2] === 'all') {
+2 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ConfigModule } from './config';
import { GqlModule } from './graphql.module';
import { MetricsModule } from './metrics';
import { BusinessModules } from './modules';
import { PrismaModule } from './prisma';
import { StorageModule } from './storage';
@@ -10,9 +10,9 @@ import { StorageModule } from './storage';
@Module({
imports: [
PrismaModule,
GqlModule,
ConfigModule.forRoot(),
StorageModule.forRoot(),
MetricsModule,
...BusinessModules,
],
controllers: [AppController],
+61 -3
View File
@@ -77,6 +77,10 @@ export interface AFFiNEConfig {
* System version
*/
readonly version: string;
/**
* Deployment environment
*/
readonly affineEnv: 'dev' | 'beta' | 'production';
/**
* alias to `process.env.NODE_ENV`
*
@@ -84,12 +88,22 @@ export interface AFFiNEConfig {
* @env NODE_ENV
*/
readonly env: string;
/**
* fast AFFiNE environment judge
*/
get affine(): {
canary: boolean;
beta: boolean;
stable: boolean;
};
/**
* fast environment judge
*/
get prod(): boolean;
get dev(): boolean;
get test(): boolean;
get node(): {
prod: boolean;
dev: boolean;
test: boolean;
};
get deploy(): boolean;
/**
@@ -167,6 +181,28 @@ export interface AFFiNEConfig {
path: string;
};
};
/**
* Redis Config
*
* whether to use redis as Socket.IO adapter
*/
redis: {
/**
* if not enabled, use in-memory adapter by default
*/
enabled: boolean;
/**
* url of redis host
*/
host: string;
/**
* port of redis
*/
port: number;
username: string;
password: string;
database: number;
};
/**
* authentication config
@@ -236,8 +272,30 @@ export interface AFFiNEConfig {
email: {
server: string;
port: number;
login: string;
sender: string;
password: string;
};
};
doc: {
manager: {
/**
* How often the [DocManager] will start a new turn of merging pending updates into doc snapshot.
*
* This is not the latency a new joint client will take to see the latest doc,
* but the buffer time we introduced to reduce the load of our service.
*
* in {ms}
*/
updatePollInterval: number;
/**
* Use JwstCodec to merge updates at the same time when merging using Yjs.
*
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
*/
experimentalMergeWithJwstCodec: boolean;
};
};
}
+51 -13
View File
@@ -51,37 +51,60 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
serverId: 'affine-nestjs-server',
version: pkg.version,
ENV_MAP: {
AFFINE_SERVER_PORT: 'port',
AFFINE_SERVER_PORT: ['port', 'int'],
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFINE_ENV: 'affineEnv',
DATABASE_URL: 'db.url',
AUTH_PRIVATE_KEY: 'auth.privateKey',
ENABLE_R2_OBJECT_STORAGE: 'objectStorage.r2.enabled',
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',
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
OAUTH_EMAIL_LOGIN: 'auth.email.login',
OAUTH_EMAIL_SENDER: 'auth.email.sender',
OAUTH_EMAIL_SERVER: 'auth.email.server',
OAUTH_EMAIL_PORT: 'auth.email.port',
OAUTH_EMAIL_PORT: ['auth.email.port', 'int'],
OAUTH_EMAIL_PASSWORD: 'auth.email.password',
REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'],
REDIS_SERVER_HOST: 'redis.host',
REDIS_SERVER_PORT: ['redis.port', 'int'],
REDIS_SERVER_USER: 'redis.username',
REDIS_SERVER_PASSWORD: 'redis.password',
REDIS_SERVER_DATABASE: ['redis.database', 'int'],
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
DOC_MERGE_USE_JWST_CODEC: [
'doc.manager.experimentalMergeWithJwstCodec',
'boolean',
],
} satisfies AFFiNEConfig['ENV_MAP'],
affineEnv: 'dev',
get affine() {
const env = this.affineEnv;
return {
canary: env === 'dev',
beta: env === 'beta',
stable: env === 'production',
};
},
env: process.env.NODE_ENV ?? 'development',
get prod() {
return this.env === 'production';
},
get dev() {
return this.env === 'development';
},
get test() {
return this.env === 'test';
get node() {
const env = this.env;
return {
prod: env === 'production',
dev: env === 'development',
test: env === 'test',
};
},
get deploy() {
return !this.dev && !this.test;
return !this.node.dev && !this.node.test;
},
https: false,
host: 'localhost',
@@ -91,7 +114,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
url: '',
},
get origin() {
return this.dev
return this.node.dev
? 'http://localhost:8080'
: `${this.https ? 'https' : 'http'}://${this.host}${
this.host === 'localhost' ? `:${this.port}` : ''
@@ -124,6 +147,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
email: {
server: 'smtp.gmail.com',
port: 465,
login: '',
sender: '',
password: '',
},
@@ -140,6 +164,20 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
path: join(homedir(), '.affine-storage'),
},
},
redis: {
enabled: false,
host: '127.0.0.1',
port: 6379,
username: '',
password: '',
database: 0,
},
doc: {
manager: {
updatePollInterval: 3000,
experimentalMergeWithJwstCodec: false,
},
},
} satisfies AFFiNEConfig;
applyEnvToConfig(defaultConfig);
+3
View File
@@ -0,0 +1,3 @@
export const OPERATION_NAME = 'x-operation-name';
export const REQUEST_ID = 'x-request-id';
+10 -2
View File
@@ -2,17 +2,20 @@ import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { Request, Response } from 'express';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { Config } from './config';
import { GQLLoggerPlugin } from './graphql/logger-plugin';
import { Metrics } from './metrics/metrics';
@Global()
@Module({
imports: [
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: Config) => {
useFactory: (config: Config, metrics: Metrics) => {
return {
...config.graphql,
path: `${config.path}/graphql`,
@@ -24,9 +27,14 @@ import { Config } from './config';
'..',
'schema.gql'
),
context: ({ req, res }: { req: Request; res: Response }) => ({
req,
res,
}),
plugins: [new GQLLoggerPlugin(metrics)],
};
},
inject: [Config],
inject: [Config, Metrics],
}),
],
})
+60
View File
@@ -0,0 +1,60 @@
import {
ApolloServerPlugin,
GraphQLRequestContext,
GraphQLRequestListener,
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { Logger } from '@nestjs/common';
import { Response } from 'express';
import { OPERATION_NAME, REQUEST_ID } from '../constants';
import { Metrics } from '../metrics/metrics';
import { ReqContext } from '../types';
@Plugin()
export class GQLLoggerPlugin implements ApolloServerPlugin {
protected logger = new Logger(GQLLoggerPlugin.name);
constructor(private readonly metrics: Metrics) {}
requestDidStart(
reqContext: GraphQLRequestContext<ReqContext>
): Promise<GraphQLRequestListener<GraphQLRequestContext<ReqContext>>> {
const res = reqContext.contextValue.req.res as Response;
const operation = reqContext.request.operationName;
const headers = reqContext.request.http?.headers;
const requestId = headers
? headers.get(`${REQUEST_ID}`)
: 'Unknown Request ID';
const operationName = headers
? headers.get(`${OPERATION_NAME}`)
: 'Unknown Operation Name';
this.metrics.gqlRequest(1, { operation });
const timer = this.metrics.gqlTimer({ operation });
const requestInfo = `${REQUEST_ID}: ${requestId}, ${OPERATION_NAME}: ${operationName}`;
return Promise.resolve({
willSendResponse: () => {
const costInMilliseconds = timer() * 1000;
res.setHeader(
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL"`
);
this.logger.log(requestInfo);
return Promise.resolve();
},
didEncounterErrors: () => {
this.metrics.gqlError(1, { operation });
const costInMilliseconds = timer() * 1000;
res.setHeader(
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
);
this.logger.error(`${requestInfo}, query: ${reqContext.request.query}`);
return Promise.resolve();
},
});
}
}
+64 -8
View File
@@ -1,6 +1,22 @@
/// <reference types="./global.d.ts" />
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
CompositePropagator,
W3CBaggagePropagator,
W3CTraceContextPropagator,
} from '@opentelemetry/core';
import gql from '@opentelemetry/instrumentation-graphql';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import ioredis from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import socketIO from '@opentelemetry/instrumentation-socket.io';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { PrismaInstrumentation } from '@prisma/instrumentation';
import cookieParser from 'cookie-parser';
import { static as staticMiddleware } from 'express';
// @ts-expect-error graphql-upload is not typed
@@ -8,19 +24,47 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from './app';
import { Config } from './config';
import { serverTimingAndCache } from './middleware/timing';
import { RedisIoAdapter } from './modules/sync/redis-adapter';
const { NODE_ENV } = process.env;
if (NODE_ENV === 'production') {
const traceExporter = new TraceExporter();
const tracing = new NodeSDK({
traceExporter,
metricReader: new PeriodicExportingMetricReader({
exporter: new MetricExporter(),
}),
spanProcessor: new BatchSpanProcessor(traceExporter),
textMapPropagator: new CompositePropagator({
propagators: [
new W3CBaggagePropagator(),
new W3CTraceContextPropagator(),
],
}),
instrumentations: [
new NestInstrumentation(),
new ioredis.IORedisInstrumentation(),
new socketIO.SocketIoInstrumentation({ traceReserved: true }),
new gql.GraphQLInstrumentation({ mergeItems: true }),
new HttpInstrumentation(),
new PrismaInstrumentation(),
],
serviceName: 'affine-cloud',
});
tracing.start();
}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: {
origin:
process.env.AFFINE_ENV === 'preview'
? ['https://affine-preview.vercel.app']
: ['http://localhost:8080'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['x-operation-name', 'x-definition-name'],
},
cors: true,
bodyParser: true,
logger: NODE_ENV === 'production' ? ['log'] : ['verbose'],
});
app.use(serverTimingAndCache);
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
@@ -39,6 +83,18 @@ 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(
config.redis.host,
config.redis.port,
config.redis.username,
config.redis.password,
config.redis.database
);
app.useWebSocketAdapter(redisIoAdapter);
}
await app.listen(port, host);
console.log(`Listening on http://${host}:${port}`);
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
import { register } from 'prom-client';
import { PrismaService } from '../prisma';
@Controller()
export class MetricsController {
constructor(private readonly prisma: PrismaService) {}
@Get('/metrics')
async index(@Res() res: Response): Promise<void> {
res.header('Content-Type', register.contentType);
const prismaMetrics = await this.prisma.$metrics.prometheus();
const appMetrics = await register.metrics();
res.send(appMetrics + prismaMetrics);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { MetricsController } from '../metrics/controller';
import { Metrics } from './metrics';
@Global()
@Module({
providers: [Metrics],
exports: [Metrics],
controllers: [MetricsController],
})
export class MetricsModule {}
+25
View File
@@ -0,0 +1,25 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { register } from 'prom-client';
import { metricsCreator } from './utils';
@Injectable()
export class Metrics implements OnModuleDestroy {
onModuleDestroy(): void {
register.clear();
}
socketIOEventCounter = metricsCreator.counter('socket_io_counter', ['event']);
socketIOEventTimer = metricsCreator.timer('socket_io_timer', ['event']);
socketIOConnectionGauge = metricsCreator.gauge(
'socket_io_connection_counter'
);
gqlRequest = metricsCreator.counter('gql_request', ['operation']);
gqlError = metricsCreator.counter('gql_error', ['operation']);
gqlTimer = metricsCreator.timer('gql_timer', ['operation']);
jwstCodecMerge = metricsCreator.counter('jwst_codec_merge');
jwstCodecDidnotMatch = metricsCreator.counter('jwst_codec_didnot_match');
jwstCodecFail = metricsCreator.counter('jwst_codec_fail');
}
+73
View File
@@ -0,0 +1,73 @@
import { Counter, Gauge, Summary } from 'prom-client';
type LabelValues<T extends string> = Partial<Record<T, string | number>>;
type MetricsCreator<T extends string> = (
value: number,
labels: LabelValues<T>
) => void;
type TimerMetricsCreator<T extends string> = (
labels: LabelValues<T>
) => () => number;
export const metricsCreatorGenerator = () => {
const counterCreator = <T extends string>(
name: string,
labelNames?: T[]
): MetricsCreator<T> => {
const counter = new Counter({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (value: number, labels: LabelValues<T>) => {
counter.inc(labels, value);
};
};
const gaugeCreator = <T extends string>(
name: string,
labelNames?: T[]
): MetricsCreator<T> => {
const gauge = new Gauge({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (value: number, labels: LabelValues<T>) => {
gauge.set(labels, value);
};
};
const timerCreator = <T extends string>(
name: string,
labelNames?: T[]
): TimerMetricsCreator<T> => {
const summary = new Summary({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (labels: LabelValues<T>) => {
const now = process.hrtime();
return () => {
const delta = process.hrtime(now);
const value = delta[0] + delta[1] / 1e9;
summary.observe(labels, value);
return value;
};
};
};
return {
counter: counterCreator,
gauge: gaugeCreator,
timer: timerCreator,
};
};
export const metricsCreator = metricsCreatorGenerator();
+27
View File
@@ -0,0 +1,27 @@
import { NextFunction, Request, Response } from 'express';
import onHeaders from 'on-headers';
export const serverTimingAndCache = (
req: Request,
res: Response,
next: NextFunction
) => {
req.res = res;
const now = process.hrtime();
onHeaders(res, () => {
const delta = process.hrtime(now);
const costInMilliseconds = (delta[0] + delta[1] / 1e9) * 1000;
const serverTiming = res.getHeader('Server-Timing') as string | undefined;
const serverTimingValue = `${
serverTiming ? `${serverTiming}, ` : ''
}total;dur=${costInMilliseconds}`;
res.setHeader('Server-Timing', serverTimingValue);
});
res.setHeader('Cache-Control', 'max-age=0, private, must-revalidate');
next();
};
+73 -13
View File
@@ -1,8 +1,18 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, Injectable, UseGuards } from '@nestjs/common';
import {
createParamDecorator,
Inject,
Injectable,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
export function getUserFromContext(context: ExecutionContext) {
@@ -42,26 +52,71 @@ export const CurrentUser = createParamDecorator(
@Injectable()
class AuthGuard implements CanActivate {
constructor(
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private auth: AuthService,
private prisma: PrismaService
private prisma: PrismaService,
private readonly reflector: Reflector
) {}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const { req, res } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
if (!token) {
return false;
}
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
// api is public
const isPublic = this.reflector.get<boolean>(
'isPublic',
context.getHandler()
);
// api can be public, but if user is logged in, we can get user info
const isPublicable = this.reflector.get<boolean>(
'isPublicable',
context.getHandler()
);
if (isPublic) {
return true;
} else if (!token) {
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
return !!req.user;
}
const { body = {}, cookies, status = 200 } = session;
if (!body && !isPublicable) {
return false;
}
// @ts-expect-error body is user here
req.user = body.user;
if (cookies && res) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
return Boolean(
status === 200 &&
typeof body !== 'string' &&
// ignore body if api is publicable
(Object.keys(body).length || isPublicable)
);
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
}
return false;
}
}
@@ -85,3 +140,8 @@ class AuthGuard implements CanActivate {
export const Auth = () => {
return UseGuards(AuthGuard);
};
// api is public accessible
export const Public = () => SetMetadata('isPublic', true);
// api is public accessible, but if user is logged in, we can get user info
export const Publicable = () => SetMetadata('isPublicable', true);
+10 -2
View File
@@ -1,13 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { MAILER, MailService } from './mailer';
import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Global()
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
providers: [
AuthService,
AuthResolver,
NextAuthOptionsProvider,
MAILER,
MailService,
],
exports: [AuthService, NextAuthOptionsProvider, MailService],
controllers: [NextAuthController],
})
export class AuthModule {}
@@ -0,0 +1,2 @@
export { MailService } from './mail.service';
export { MAILER } from './mailer';
@@ -0,0 +1,130 @@
import { Inject, Injectable } from '@nestjs/common';
import { Config } from '../../../config';
import {
MAILER_SERVICE,
type MailerService,
type Options,
type Response,
} from './mailer';
import { emailTemplate } from './template';
@Injectable()
export class MailService {
constructor(
@Inject(MAILER_SERVICE) private readonly mailer: MailerService,
private readonly config: Config
) {}
async sendMail(options: Options): Promise<Response> {
return this.mailer.sendMail(options);
}
hasConfigured() {
return (
!!this.config.auth.email.login &&
!!this.config.auth.email.password &&
!!this.config.auth.email.sender
);
}
async sendInviteEmail(
to: string,
inviteId: string,
invitationInfo: {
workspace: {
id: string;
name: string;
avatar: string;
};
user: {
avatar: string;
name: string;
};
}
) {
console.log('invitationInfo', invitationInfo);
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
const workspaceAvatar = invitationInfo.workspace.avatar;
const content = ` <img
src="${invitationInfo.user.avatar}"
alt=""
width="24px"
height="24px"
style="border-radius: 12px;object-fit: cover;vertical-align: middle"
/>
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.user.name}</span>
<span>invited you to join</span>
<img
src="cid:workspaceAvatar"
alt=""
width="24px"
height="24px"
style="margin-left:10px;border-radius: 12px;object-fit: cover;vertical-align: middle"
/>
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.workspace.name}</span>`;
const html = emailTemplate({
title: 'You are invited!',
content,
buttonContent: 'Accept & Join',
buttonUrl,
});
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Invitation to workspace`,
html,
attachments: [
{
cid: 'workspaceAvatar',
filename: 'image.png',
content: workspaceAvatar,
encoding: 'base64',
},
],
});
}
async sendChangePasswordEmail(to: string, url: string) {
const html = `
<h1>Change password</h1>
<p>Click button to open change password page</p>
<a href="${url}">${url}</a>
`;
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
html,
});
}
async sendSetPasswordEmail(to: string, url: string) {
const html = `
<h1>Set password</h1>
<p>Click button to open set password page</p>
<a href="${url}">${url}</a>
`;
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
html,
});
}
async sendChangeEmail(to: string, url: string) {
const html = `
<h1>Change Email</h1>
<p>Click button to open change email page</p>
<a href="${url}">${url}</a>
`;
return this.sendMail({
from: this.config.auth.email.sender,
to,
subject: `Change password`,
html,
});
}
}
@@ -0,0 +1,27 @@
import { FactoryProvider } from '@nestjs/common';
import { createTransport, Transporter } from 'nodemailer';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import { Config } from '../../../config';
export const MAILER_SERVICE = Symbol('MAILER_SERVICE');
export type MailerService = Transporter<SMTPTransport.SentMessageInfo>;
export type Response = SMTPTransport.SentMessageInfo;
export type Options = SMTPTransport.Options;
export const MAILER: FactoryProvider<
Transporter<SMTPTransport.SentMessageInfo>
> = {
provide: MAILER_SERVICE,
useFactory: (config: Config) => {
return createTransport({
service: 'gmail',
auth: {
user: config.auth.email.login,
pass: config.auth.email.password,
},
});
},
inject: [Config],
};
@@ -0,0 +1,195 @@
export const emailTemplate = ({
title,
content,
buttonContent,
buttonUrl,
}: {
title: string;
content: string;
buttonContent: string;
buttonUrl: string;
}) => {
return `<body style="background: #f6f7fb; overflow: hidden">
<table
width="100%"
border="0"
cellpadding="24px"
style="
background: #fff;
max-width: 450px;
margin: 32px auto 0 auto;
border-radius: 16px 16px 0 0;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
"
>
<tr>
<td>
<a href="https://affine.pro" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE log"
height="32px"
/>
</a>
</td>
</tr>
<tr>
<td
style="
font-size: 20px;
font-weight: 600;
line-height: 28px;
font-family: Inter;
color: #444;
padding-top: 0;
"
>${title}</td>
</tr>
<tr>
<td
style="
font-size: 15px;
font-weight: 400;
line-height: 24px;
font-family: Inter;
color: #444;
padding-top: 0;
"
>${content}</td>
</tr>
<tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 8px" bgcolor="#1E96EB">
<a
href="${buttonUrl}"
target="_blank"
style="
font-size: 15px;
font-family: Inter;
font-weight: 600;
line-height: 24px;
color: #fff;
text-decoration: none;
border-radius: 8px;
padding: 8px 18px;
border: 1px solid #1e96eb;
display: inline-block;
font-weight: bold;
"
>${buttonContent}</a
>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table
width="100%"
border="0"
style="
background: #fafafa;
max-width: 450px;
margin: 0 auto 32px auto;
border-radius: 0 0 16px 16px;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
padding: 20px;
"
>
<tr align="center">
<td>
<table cellpadding="0">
<tr>
<td style="padding: 0 10px">
<a
href="https://github.com/toeverything/AFFiNE"
target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
alt="AFFiNE github link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://twitter.com/AffineOfficial" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
alt="AFFiNE twitter link"
height="16px"
/>
</a>
</td>
<td style="padding: 0 10px">
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
alt="AFFiNE discord link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.youtube.com/@affinepro" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
alt="AFFiNE youtube link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://t.me/affineworkos" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
alt="AFFiNE telegram link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.reddit.com/r/Affine/" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
alt="AFFiNE reddit link"
height="16px"
/></a>
</td>
</tr>
</table>
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: Inter;
color: #8e8d91;
padding-top: 8px;
"
>
One hyper-fused platform for wildly creative minds
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: Inter;
color: #8e8d91;
padding-top: 8px;
"
>
Copyright<img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style="vertical-align: middle; margin: 0 4px"
/>2023 Toeverything
</td>
</tr>
</table>
</body>`;
};
@@ -0,0 +1,501 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
import { verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { NextAuthOptions } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Email, {
type SendVerificationRequestParams,
} from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { NewFeaturesKind } from '../users/types';
import { MailService } from './mailer';
import { getUtcTimestamp, UserClaim } from './service';
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
const { searchParams } = new URL(callbackUrl, origin);
return searchParams.has('schema') ? searchParams.get('schema') : null;
}
function wrapUrlWithSchema(url: string, schema: string | null) {
if (schema) {
return `${schema}://open-url?${url}`;
}
return url;
}
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
provide: NextAuthOptionsProvide,
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
const logger = new Logger('NextAuth');
const prismaAdapter = PrismaAdapter(prisma);
// createUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
prismaAdapter.createUser = async data => {
const userData = {
name: data.name,
email: data.email,
avatarUrl: '',
emailVerified: data.emailVerified,
};
if (data.email && !data.name) {
userData.name = data.email.split('@')[0];
}
if (data.image) {
userData.avatarUrl = data.image;
}
return createUser(userData);
};
// getUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
prismaAdapter.getUser = async id => {
const result = await getUser(id);
if (result) {
// @ts-expect-error Third part library type mismatch
result.image = result.avatarUrl;
// @ts-expect-error Third part library type mismatch
result.hasPassword = Boolean(result.password);
}
return result;
};
const nextAuthOptions: NextAuthOptions = {
providers: [
// @ts-expect-error esm interop issue
Email.default({
server: {
host: config.auth.email.server,
port: config.auth.email.port,
auth: {
user: config.auth.email.login,
pass: config.auth.email.password,
},
},
from: config.auth.email.sender,
async sendVerificationRequest(params: SendVerificationRequestParams) {
const { identifier, url, provider } = params;
const { host, searchParams, origin } = new URL(url);
const callbackUrl = searchParams.get('callbackUrl') || '';
if (!callbackUrl) {
throw new Error('callbackUrl is not set');
}
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
const wrappedUrl = wrapUrlWithSchema(url, schema);
// hack: check if link is opened via desktop
const result = await mailer.sendMail({
to: identifier,
from: provider.from,
subject: `Sign in to ${host}`,
text: text({ url: wrappedUrl, host }),
html: html({ url: wrappedUrl, host }),
});
logger.log(
`send verification email success: ${result.accepted.join(', ')}`
);
const failed = result.rejected
.concat(result.pending)
.filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
}
},
}),
],
// @ts-expect-error Third part library type mismatch
adapter: prismaAdapter,
debug: !config.node.prod,
session: {
strategy: config.node.prod ? 'database' : 'jwt',
},
// @ts-expect-error Third part library type mismatch
logger: console,
};
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Credentials.default({
name: 'Password',
credentials: {
email: {
label: 'Email',
type: 'text',
placeholder: 'torvalds@osdl.org',
},
password: { label: 'Password', type: 'password' },
},
async authorize(
credentials:
| Record<'email' | 'password' | 'hashedPassword', string>
| undefined
) {
if (!credentials) {
return null;
}
const { password, hashedPassword } = credentials;
if (!password || !hashedPassword) {
return null;
}
if (!(await verify(hashedPassword, password))) {
return null;
}
return credentials;
},
})
);
if (config.auth.oauthProviders.github) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Github.default({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
allowDangerousEmailAccountLinking: true,
})
);
}
if (config.auth.oauthProviders.google) {
nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Google.default({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
checks: 'nonce',
allowDangerousEmailAccountLinking: true,
})
);
}
nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
picture: user.avatarUrl,
createdAt: user.createdAt.toISOString(),
hasPassword: Boolean(user.password),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
},
decode: async ({ token }) => {
if (!token) {
return null;
}
const { name, email, emailVerified, id, picture, hasPassword } = (
await jwtVerify(token, config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [config.serverId],
leeway: config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as Omit<UserClaim, 'avatarUrl'> & {
picture: string | undefined;
};
return {
name,
email,
emailVerified,
picture,
sub: id,
id,
hasPassword,
};
},
};
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
nextAuthOptions.callbacks = {
session: async ({ session, user, token }) => {
if (session.user) {
if (user) {
// @ts-expect-error Third part library type mismatch
session.user.id = user.id;
// @ts-expect-error Third part library type mismatch
session.user.image = user.image ?? user.avatarUrl;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = user.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = Boolean(user.password);
} else {
// technically the sub should be the same as id
// @ts-expect-error Third part library type mismatch
session.user.id = token.sub;
// @ts-expect-error Third part library type mismatch
session.user.emailVerified = token.emailVerified;
// @ts-expect-error Third part library type mismatch
session.user.hasPassword = token.hasPassword;
}
if (token && token.picture) {
session.user.image = token.picture;
}
}
return session;
},
signIn: async ({ profile }) => {
if (!config.affine.beta || !config.node.prod) {
return true;
}
if (profile?.email) {
return await prisma.newFeaturesWaitingList
.findUnique({
where: {
email: profile.email,
type: NewFeaturesKind.EarlyAccess,
},
})
.then(user => !!user)
.catch(() => false);
}
return false;
},
redirect({ url }) {
return url;
},
};
return nextAuthOptions;
},
inject: [Config, PrismaService, MailService],
};
/**
* Email HTML body
* Insert invisible space into domains from being turned into a hyperlink by email
* clients like Outlook and Apple mail, as this is confusing because it seems
* like they are supposed to click on it to sign in.
*
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/
function html(params: { url: string; host: string }) {
const { url } = params;
return `
<body style="background: #f6f7fb;overflow:hidden">
<table
width="100%"
border="0"
cellpadding="24px"
style="
background: #fff;
max-width: 450px;
margin: 32px auto 0 auto;
border-radius: 16px 16px 0 0;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
"
>
<tr>
<td>
<a href="https://affine.pro" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
alt="AFFiNE log"
height="32px"
/>
</a>
</td>
</tr>
<tr>
<td
style="
font-size: 20px;
font-weight: 600;
line-height: 28px;
font-family: Inter;
color: #444;
padding-top: 0;
"
>
Verify your new email for AFFiNE
</td>
</tr>
<tr>
<td
style="
font-size: 15px;
font-weight: 400;
line-height: 24px;
font-family: Inter;
color: #444;
padding-top: 0;
"
>
You recently requested to change the email address associated with your
AFFiNe account. To complete this process, please click on the
verification link below.
</td>
</tr>
<tr>
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="border-radius: 8px" bgcolor="#1E96EB">
<a
href="${url}"
target="_blank"
style="
font-size: 15px;
font-family: Inter;
font-weight: 600;
line-height: 24px;
color: #fff;
text-decoration: none;
border-radius: 8px;
padding: 8px 18px;
border: 1px solid #1e96eb;
display: inline-block;
font-weight: bold;
"
>Verify your new email address</a
>
</td>
</tr>
</table>
</td>
</tr>
</table>
<table
width="100%"
border="0"
style="
background: #fafafa;
max-width: 450px;
margin: 0 auto 32px auto;
border-radius: 0 0 16px 16px;
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
padding: 20px;
"
>
<tr align="center">
<td>
<table cellpadding="0">
<tr>
<td style="padding: 0 10px">
<a href="https://github.com/toeverything/AFFiNE" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
alt="AFFiNE github link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://twitter.com/AffineOfficial" target="_blank">
<img
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
alt="AFFiNE twitter link"
height="16px"
/>
</a>
</td>
<td style="padding: 0 10px">
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
alt="AFFiNE discord link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.youtube.com/@affinepro" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
alt="AFFiNE youtube link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://t.me/affineworkos" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
alt="AFFiNE telegram link"
height="16px"
/></a>
</td>
<td style="padding: 0 10px">
<a href="https://www.reddit.com/r/Affine/" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
alt="AFFiNE reddit link"
height="16px"
/></a>
</td>
</tr>
</table>
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: Inter;
color: #8e8d91;
padding-top: 8px;
"
>
One hyper-fused platform for wildly creative minds
</td>
</tr>
<tr align="center">
<td
style="
font-size: 12px;
font-weight: 400;
line-height: 20px;
font-family: Inter;
color: #8e8d91;
padding-top: 8px;
"
>
Copyright<img
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
alt="copyright"
height="14px"
style="vertical-align: middle; margin: 0 4px"
/>2023 Toeverything
</td>
</tr>
</table>
</body>
`;
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`;
}
@@ -1,141 +1,41 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter';
import {
All,
BadRequestException,
Controller,
Inject,
Next,
NotFoundException,
Query,
Req,
Res,
} from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import { hash, verify } from '@node-rs/argon2';
import type { User } from '@prisma/client';
import type { NextFunction, Request, Response } from 'express';
import type { AuthAction, AuthOptions } from 'next-auth';
import { pick } from 'lodash-es';
import type { AuthAction, NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import Email from 'next-auth/providers/email';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import { getUtcTimestamp, type UserClaim } from './service';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
const BASE_URL = '/api/auth/';
@Controller(BASE_URL)
export class NextAuthController {
private readonly nextAuthOptions: AuthOptions;
private readonly callbackSession;
constructor(
readonly config: Config,
readonly prisma: PrismaService
readonly prisma: PrismaService,
private readonly authService: AuthService,
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions
) {
const prismaAdapter = PrismaAdapter(prisma);
// createUser exists in the adapter
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
prismaAdapter.createUser = async data => {
if (data.email && !data.name) {
data.name = data.email.split('@')[0];
}
return createUser(data);
};
this.nextAuthOptions = {
providers: [
// @ts-expect-error esm interop issue
Email.default({
server: {
host: config.auth.email.server,
port: config.auth.email.port,
auth: {
user: config.auth.email.sender,
pass: config.auth.email.password,
},
},
from: `AFFiNE <no-reply@toeverything.info>`,
}),
],
// @ts-expect-error Third part library type mismatch
adapter: prismaAdapter,
debug: !config.prod,
};
if (config.auth.oauthProviders.github) {
this.nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Github.default({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
})
);
}
if (config.auth.oauthProviders.google) {
this.nextAuthOptions.providers.push(
// @ts-expect-error esm interop issue
Google.default({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
})
);
}
this.nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await this.prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
},
decode: async ({ token }) => {
if (!token) {
return null;
}
const { name, email, id } = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
return {
name,
email,
sub: id,
};
},
};
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
this.callbackSession = nextAuthOptions.callbacks!.session;
}
@All('*')
@@ -145,25 +45,69 @@ export class NextAuthController {
@Query() query: Record<string, any>,
@Next() next: NextFunction
) {
const nextauth = req.url // start with request url
const [action, providerId] = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as AuthAction[]; // as array of strings;
.split('/') as [AuthAction, string]; // as array of strings;
if (providerId === 'credentials') {
const { email } = req.body;
if (email) {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
req.statusCode = 401;
req.statusMessage = 'User not found';
req.body = null;
throw new NotFoundException(`User not found`);
} else {
req.body = {
...req.body,
name: user.name,
email: user.email,
image: user.avatarUrl,
hashedPassword: user.password,
};
}
}
}
const options = this.nextAuthOptions;
if (req.method === 'POST' && action === 'session') {
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
throw new BadRequestException(`Invalid new session data`);
}
const user = await this.updateSession(req, req.body.data);
// callbacks.session existed
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = ({ session }) => {
return {
user: {
...pick(user, 'id', 'name', 'email'),
image: user.avatarUrl,
hasPassword: !!user.password,
},
expires: session.expires,
};
};
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
options.callbacks!.session = this.callbackSession;
}
const { status, headers, body, redirect, cookies } = await AuthHandler({
req: {
body: req.body,
query: query,
method: req.method,
action: nextauth[0],
providerId: nextauth[1],
error: query.error ?? nextauth[1],
action,
providerId,
error: query.error ?? providerId,
cookies: req.cookies,
},
options: this.nextAuthOptions,
options,
});
if (status) {
res.status(status);
}
if (headers) {
for (const { key, value } of headers) {
res.setHeader(key, value);
@@ -174,8 +118,32 @@ export class NextAuthController {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
res.redirect('https://community.affine.pro/c/insider-general/');
return;
}
if (status) {
res.status(status);
}
if (redirect) {
res.redirect(redirect);
console.log(providerId, action, req.headers);
if (providerId === 'credentials') {
res.send(JSON.stringify({ ok: true, url: redirect }));
} else if (
action === 'callback' ||
action === 'error' ||
(providerId !== 'credentials' &&
// login in the next-auth page, /api/auth/signin, auto redirect.
// otherwise, return the json value to allow frontend to handle the redirect.
req.headers?.referer?.includes?.('/api/auth/signin'))
) {
res.redirect(redirect);
} else {
res.json({ url: redirect });
}
} else if (typeof body === 'string') {
res.send(body);
} else if (body && typeof body === 'object') {
@@ -184,4 +152,91 @@ export class NextAuthController {
next();
}
}
private async updateSession(
req: Request,
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
): Promise<User> {
const { name, email, password, oldPassword } = newSession;
if (!name && !email && !password) {
throw new BadRequestException(`Invalid new session data`);
}
if (password) {
const user = await this.getUserFromRequest(req);
const { password: userPassword } = user;
if (!oldPassword) {
if (userPassword) {
throw new BadRequestException(
`Old password is required to update password`
);
}
} else {
if (!userPassword) {
throw new BadRequestException(`No existed password`);
}
if (await verify(userPassword, oldPassword)) {
await this.prisma.user.update({
where: {
id: user.id,
},
data: {
...pick(newSession, 'email', 'name'),
password: await hash(password),
},
});
}
}
return user;
} else {
const user = await this.getUserFromRequest(req);
return this.prisma.user.update({
where: {
id: user.id,
},
data: pick(newSession, 'name', 'email'),
});
}
}
private async getUserFromRequest(req: Request): Promise<User> {
const token = req.headers.authorization;
if (!token) {
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
const { body } = session;
// @ts-expect-error check if body.user exists
if (body && body.user && body.user.id) {
const user = await this.prisma.user.findUnique({
where: {
// @ts-expect-error body.user.id exists
id: body.user.id,
},
});
if (user) {
return user;
}
}
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.authService.verify(jwt);
const user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
if (user) {
return user;
}
}
}
throw new BadRequestException(`User not found`);
}
}
+59 -3
View File
@@ -11,6 +11,7 @@ import {
} from '@nestjs/graphql';
import type { Request } from 'express';
import { Config } from '../../config';
import { UserType } from '../users/resolver';
import { CurrentUser } from './guard';
import { AuthService } from './service';
@@ -26,7 +27,10 @@ export class TokenType {
@Resolver(() => UserType)
export class AuthResolver {
constructor(private auth: AuthService) {}
constructor(
private readonly config: Config,
private auth: AuthService
) {}
@ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
@@ -41,13 +45,13 @@ export class AuthResolver {
}
@Mutation(() => UserType)
async register(
async signUp(
@Context() ctx: { req: Request },
@Args('name') name: string,
@Args('email') email: string,
@Args('password') password: string
) {
const user = await this.auth.register(name, email, password);
const user = await this.auth.signUp(name, email, password);
ctx.req.user = user;
return user;
}
@@ -62,4 +66,56 @@ export class AuthResolver {
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async changePassword(
@Context() ctx: { req: Request },
@Args('id') id: string,
@Args('newPassword') newPassword: string
) {
const user = await this.auth.changePassword(id, newPassword);
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async changeEmail(
@Context() ctx: { req: Request },
@Args('id') id: string,
@Args('email') email: string
) {
const user = await this.auth.changeEmail(id, email);
ctx.req.user = user;
return user;
}
@Mutation(() => Boolean)
async sendChangePasswordEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendChangePasswordEmail(email, url);
return !res.rejected.length;
}
@Mutation(() => Boolean)
async sendSetPasswordEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendSetPasswordEmail(email, url);
return !res.rejected.length;
}
@Mutation(() => Boolean)
async sendChangeEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendChangeEmail(email, url);
return !res.rejected.length;
}
}
+115 -4
View File
@@ -12,8 +12,14 @@ import type { User } from '@prisma/client';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { MailService } from './mailer';
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
export type UserClaim = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
> & {
hasPassword?: boolean;
};
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
@@ -21,7 +27,8 @@ export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
export class AuthService {
constructor(
private config: Config,
private prisma: PrismaService
private prisma: PrismaService,
private mailer: MailService
) {}
sign(user: UserClaim) {
@@ -32,6 +39,9 @@ export class AuthService {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
iat: now,
@@ -58,6 +68,9 @@ export class AuthService {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
@@ -78,7 +91,7 @@ export class AuthService {
async verify(token: string) {
try {
return (
const data = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
@@ -86,6 +99,12 @@ export class AuthService {
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
return {
...data,
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
createdAt: new Date(data.createdAt),
};
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
@@ -119,7 +138,7 @@ export class AuthService {
return user;
}
async register(name: string, email: string, password: string): Promise<User> {
async signUp(name: string, email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
@@ -140,4 +159,96 @@ export class AuthService {
},
});
}
async createAnonymousUser(email: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (user) {
throw new BadRequestException('Email already exists');
}
return this.prisma.user.create({
data: {
name: 'Unnamed',
email,
},
});
}
async getUserByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: {
email,
},
});
}
async isUserHasPassword(email: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
return Boolean(user.password);
}
async changePassword(id: string, newPassword: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {
id,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await hash(newPassword);
return this.prisma.user.update({
where: {
id,
},
data: {
password: hashedPassword,
},
});
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {
id,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
return this.prisma.user.update({
where: {
id,
},
data: {
email: newEmail,
},
});
}
async sendChangePasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangePasswordEmail(email, callbackUrl);
}
async sendSetPasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendSetPasswordEmail(email, callbackUrl);
}
async sendChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangeEmail(email, callbackUrl);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { DynamicModule } from '@nestjs/common';
import { DocManager } from './manager';
import { RedisDocManager } from './redis-manager';
export class DocModule {
/**
* @param automation whether enable update merging automation logic
*/
private static defModule(automation = true): DynamicModule {
return {
module: DocModule,
providers: [
{
provide: 'DOC_MANAGER_AUTOMATION',
useValue: automation,
},
{
provide: DocManager,
useClass: globalThis.AFFiNE.redis.enabled
? RedisDocManager
: DocManager,
},
],
exports: [DocManager],
};
}
static forRoot() {
return this.defModule();
}
static forSync(): DynamicModule {
return this.defModule(false);
}
static forFeature(): DynamicModule {
return this.defModule(false);
}
}
export { DocManager };
+351
View File
@@ -0,0 +1,351 @@
import {
Inject,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
if (yBinary.equals(jwstBinary)) {
return true;
}
if (strict) {
return false;
}
const doc = new Doc();
applyUpdate(doc, jwstBinary);
const yBinary2 = Buffer.from(encodeStateAsUpdate(doc));
return compare(yBinary, yBinary2, true);
}
/**
* Since we can't directly save all client updates into database, in which way the database will overload,
* we need to buffer the updates and merge them to reduce db write.
*
* And also, if a new client join, it would be nice to see the latest doc asap,
* so we need to at least store a snapshot of the doc and return quickly,
* along side all the updates that have not been applies to that snapshot(timestamp).
*
* @see [RedisUpdateManager](./redis-manager.ts) - redis backed manager
*/
@Injectable()
export class DocManager implements OnModuleInit, OnModuleDestroy {
protected logger = new Logger(DocManager.name);
private job: NodeJS.Timeout | null = null;
private busy = false;
constructor(
protected readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION')
protected readonly automation: boolean,
protected readonly config: Config,
protected readonly metrics: Metrics
) {}
onModuleInit() {
if (this.automation) {
this.logger.log('Use Database');
this.setup();
}
}
onModuleDestroy() {
this.destroy();
}
protected recoverDoc(...updates: Buffer[]): Doc {
const doc = new Doc();
updates.forEach(update => {
applyUpdate(doc, update);
});
return doc;
}
protected yjsMergeUpdates(...updates: Buffer[]): Buffer {
const doc = this.recoverDoc(...updates);
return Buffer.from(encodeStateAsUpdate(doc));
}
protected mergeUpdates(guid: string, ...updates: Buffer[]): Buffer {
const yjsResult = this.yjsMergeUpdates(...updates);
this.metrics.jwstCodecMerge(1, {});
let log = false;
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
try {
const jwstResult = jwstMergeUpdates(updates);
if (!compare(yjsResult, jwstResult)) {
this.metrics.jwstCodecDidnotMatch(1, {});
this.logger.warn(
`jwst codec result doesn't match yjs codec result for: ${guid}`
);
log = true;
if (this.config.node.dev) {
this.logger.warn(`Expected:\n ${yjsResult.toString('hex')}`);
this.logger.warn(`Result:\n ${jwstResult.toString('hex')}`);
}
}
} catch (e) {
this.metrics.jwstCodecFail(1, {});
this.logger.warn(`jwst apply update failed for :${guid}`, e);
log = true;
} finally {
if (log) {
this.logger.warn(
'Updates:',
updates.map(u => u.toString('hex'))
);
}
}
}
return yjsResult;
}
/**
* setup pending update processing loop
*/
setup() {
this.job = setInterval(() => {
if (!this.busy) {
this.busy = true;
this.apply()
.catch(() => {
/* we handle all errors in work itself */
})
.finally(() => {
this.busy = false;
});
}
}, this.config.doc.manager.updatePollInterval);
this.logger.log('Automation started');
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
this.logger.warn(
'Experimental feature enabled: merge updates with jwst codec is enabled'
);
}
}
/**
* stop pending update processing loop
*/
destroy() {
if (this.job) {
clearInterval(this.job);
this.job = null;
this.logger.log('Automation stopped');
}
}
/**
* add update to manager for later processing like fast merging.
*/
async push(workspaceId: string, guid: string, update: Buffer) {
await this.db.update.create({
data: {
workspaceId,
id: guid,
blob: update,
},
});
this.logger.verbose(
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
);
}
/**
* get the snapshot of the doc we've seen.
*/
async getSnapshot(
workspaceId: string,
guid: string
): Promise<Buffer | undefined> {
const snapshot = await this.db.snapshot.findFirst({
where: {
workspaceId,
id: guid,
},
});
return snapshot?.blob;
}
/**
* get pending updates
*/
async getUpdates(workspaceId: string, guid: string): Promise<Buffer[]> {
const updates = await this.db.update.findMany({
where: {
workspaceId,
id: guid,
},
});
return updates.map(update => update.blob);
}
/**
* get the latest doc with all update applied.
*
* latest = snapshot + updates
*/
async getLatest(workspaceId: string, guid: string): Promise<Doc | undefined> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
if (snapshot) {
return this.recoverDoc(snapshot, ...updates);
} else {
return this.recoverDoc(...updates);
}
}
if (snapshot) {
return this.recoverDoc(snapshot);
}
return undefined;
}
/**
* get the latest doc and convert it to update binary
*/
async getLatestUpdate(
workspaceId: string,
guid: string
): Promise<Buffer | undefined> {
const doc = await this.getLatest(workspaceId, guid);
return doc ? Buffer.from(encodeStateAsUpdate(doc)) : undefined;
}
/**
* apply pending updates to snapshot
*/
async apply() {
const updates = await this.db
.$transaction(async db => {
// find the first update and batch process updates with same id
const first = await db.update.findFirst({
orderBy: {
createdAt: 'asc',
},
});
// no pending updates
if (!first) {
return;
}
const { id, workspaceId } = first;
const updates = await db.update.findMany({
where: {
id,
workspaceId,
},
});
// no pending updates
if (!updates.length) {
return;
}
// remove update that will be merged later
await db.update.deleteMany({
where: {
id,
workspaceId,
},
});
return updates;
})
.catch(
// transaction failed, it's safe to ignore
e => {
this.logger.error('Failed to fetch updates', e);
}
);
// we put update merging logic outside transaction will make the processing more complex,
// but it's better to do so, since the merging may takes a lot of time,
// which may slow down the whole db.
if (!updates?.length) {
return;
}
const { id, workspaceId } = updates[0];
this.logger.verbose(
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
);
try {
const snapshot = await this.db.snapshot.findFirst({
where: {
workspaceId,
id,
},
});
// merge updates
const merged = snapshot
? this.mergeUpdates(id, snapshot.blob, ...updates.map(u => u.blob))
: this.mergeUpdates(id, ...updates.map(u => u.blob));
// save snapshot
await this.upsert(workspaceId, id, merged);
} catch (e) {
// failed to merge updates, put them back
this.logger.error('Failed to merge updates', e);
await this.db.update
.createMany({
data: updates.map(u => ({
id: u.id,
workspaceId: u.workspaceId,
blob: u.blob,
})),
})
.catch(e => {
// failed to recover, fallback TBD
this.logger.error('Fetal: failed to put updates back to db', e);
});
}
}
protected async upsert(workspaceId: string, guid: string, blob: Buffer) {
return this.db.snapshot.upsert({
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
create: {
id: guid,
workspaceId,
blob,
},
update: {
blob,
},
});
}
}
@@ -0,0 +1,150 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { DocManager } from './manager';
function makeKey(prefix: string) {
return (parts: TemplateStringsArray, ...args: any[]) => {
return parts.reduce((prev, curr, i) => {
return prev + curr + (args[i] || '');
}, prefix);
};
}
const pending = 'um_pending:';
const updates = makeKey('um_u:');
const lock = makeKey('um_l:');
const pushUpdateLua = `
redis.call('sadd', KEYS[1], ARGV[1])
redis.call('rpush', KEYS[2], ARGV[2])
`;
@Injectable()
export class RedisDocManager extends DocManager {
private readonly redis: Redis;
constructor(
protected override readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION')
protected override readonly automation: boolean,
protected override readonly config: Config,
protected override readonly metrics: Metrics
) {
super(db, automation, config, metrics);
this.redis = new Redis(config.redis);
this.redis.defineCommand('pushDocUpdate', {
numberOfKeys: 2,
lua: pushUpdateLua,
});
}
override onModuleInit(): void {
if (this.automation) {
this.logger.log('Use Redis');
this.setup();
}
}
override async push(workspaceId: string, guid: string, update: Buffer) {
try {
const key = `${workspaceId}:${guid}`;
// @ts-expect-error custom command
this.redis.pushDocUpdate(pending, updates`${key}`, key, update);
this.logger.verbose(
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
);
} catch (e) {
return await super.push(workspaceId, guid, update);
}
}
override async getUpdates(
workspaceId: string,
guid: string
): Promise<Buffer[]> {
try {
return this.redis.lrangeBuffer(updates`${workspaceId}:${guid}`, 0, -1);
} catch (e) {
return super.getUpdates(workspaceId, guid);
}
}
override async apply(): Promise<void> {
// incase some update fallback to db
await super.apply();
const pendingDoc = await this.redis.spop(pending).catch(() => null); // safe
if (!pendingDoc) {
return;
}
const updateKey = updates`${pendingDoc}`;
const lockKey = lock`${pendingDoc}`;
const splitAt = pendingDoc.indexOf(':');
const workspaceId = pendingDoc.substring(0, splitAt);
const id = pendingDoc.substring(splitAt + 1);
// acquire the lock
const lockResult = await this.redis
.set(
lockKey,
'1',
'EX',
// 10mins, incase progress exit in between lock require & release, which is a rare.
// if the lock is really hold more then 10mins, we should check the merge logic correctness
600,
'NX'
)
.catch(() => null); // safe;
if (!lockResult) {
return;
}
try {
// fetch pending updates
const updates = await this.redis
.lrangeBuffer(updateKey, 0, -1)
.catch(() => []); // safe
if (!updates.length) {
return;
}
this.logger.verbose(
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
);
const snapshot = await this.getSnapshot(workspaceId, id);
// merge
const blob = snapshot
? this.mergeUpdates(id, snapshot, ...updates)
: this.mergeUpdates(id, ...updates);
// update snapshot
await this.upsert(workspaceId, id, blob);
// delete merged updates
await this.redis
.ltrim(updateKey, updates.length, -1)
// safe, fallback to mergeUpdates
.catch(e => {
this.logger.error('Failed to remove merged updates from Redis', e);
});
} catch (e) {
this.logger.error('Failed to merge updates with snapshot', e);
await this.redis.sadd(pending, `${workspaceId}:${id}`).catch(() => null); // safe
} finally {
await this.redis.del(lockKey);
}
}
}
+36 -1
View File
@@ -1,5 +1,40 @@
import { DynamicModule, Type } from '@nestjs/common';
import { GqlModule } from '../graphql.module';
import { AuthModule } from './auth';
import { DocModule } from './doc';
import { SyncModule } from './sync';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
const { SERVER_FLAVOR } = process.env;
const BusinessModules: (Type | DynamicModule)[] = [];
switch (SERVER_FLAVOR) {
case 'sync':
BusinessModules.push(SyncModule, DocModule.forSync());
break;
case 'graphql':
BusinessModules.push(
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
DocModule.forRoot()
);
break;
case 'allinone':
default:
BusinessModules.push(
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
SyncModule,
DocModule.forRoot()
);
break;
}
export { BusinessModules };
+9 -2
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
@@ -14,10 +15,16 @@ export class FSService {
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, key);
const destFile = join(dest, fileName);
await pipeline(file.createReadStream(), createWriteStream(destFile));
return `/assets/${destFile}`;
return `${prefix}/assets/${fileName}`;
}
}
@@ -1,5 +1,8 @@
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';
import { getStreamAsBuffer } from 'get-stream';
import { Config } from '../../config';
import { FileUpload } from '../../types';
@@ -16,14 +19,21 @@ export class StorageService {
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: file.createReadStream(),
Body: fileBuffer,
Bucket: this.config.objectStorage.r2.bucket,
Key: key,
Key: keyWithCrc32,
ContentLength: fileBuffer.length,
ContentType: mime,
})
);
return `https://avatar.affineassets.com/${key}`;
return `https://avatar.affineassets.com/${keyWithCrc32}`;
} else {
return this.fs.writeFile(key, file);
}
@@ -0,0 +1,153 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
import { Metrics } from '../../../metrics/metrics';
import { trimGuid } from '../../../utils/doc';
import { DocManager } from '../../doc';
@WebSocketGateway({
cors: process.env.NODE_ENV !== 'production',
})
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
private connectionCount = 0;
constructor(
private readonly docManager: DocManager,
private readonly metric: Metrics
) {}
@WebSocketServer()
server!: Server;
handleConnection() {
this.connectionCount++;
this.metric.socketIOConnectionGauge(this.connectionCount, {});
}
handleDisconnect() {
this.connectionCount--;
this.metric.socketIOConnectionGauge(this.connectionCount, {});
}
@SubscribeMessage('client-handshake')
async handleClientHandShake(
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
) {
this.metric.socketIOEventCounter(1, { event: 'client-handshake' });
const endTimer = this.metric.socketIOEventTimer({
event: 'client-handshake',
});
await client.join(workspaceId);
endTimer();
}
@SubscribeMessage('client-leave')
async handleClientLeave(
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
) {
this.metric.socketIOEventCounter(1, { event: 'client-leave' });
const endTimer = this.metric.socketIOEventTimer({
event: 'client-leave',
});
await client.leave(workspaceId);
endTimer();
}
@SubscribeMessage('client-update')
async handleClientUpdate(
@MessageBody()
message: {
workspaceId: string;
guid: string;
update: string;
},
@ConnectedSocket() client: Socket
) {
this.metric.socketIOEventCounter(1, { event: 'client-update' });
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
const update = Buffer.from(message.update, 'base64');
client.to(message.workspaceId).emit('server-update', message);
const guid = trimGuid(message.workspaceId, message.guid);
await this.docManager.push(message.workspaceId, guid, update);
endTimer();
}
@SubscribeMessage('doc-load')
async loadDoc(
@MessageBody()
message: {
workspaceId: string;
guid: string;
stateVector?: string;
targetClientId?: number;
}
): Promise<{ missing: string; state?: string } | false> {
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
const guid = trimGuid(message.workspaceId, message.guid);
const doc = await this.docManager.getLatest(message.workspaceId, guid);
if (!doc) {
endTimer();
return false;
}
const missing = Buffer.from(
encodeStateAsUpdate(
doc,
message.stateVector
? Buffer.from(message.stateVector, 'base64')
: undefined
)
).toString('base64');
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
endTimer();
return {
missing,
state,
};
}
@SubscribeMessage('awareness-init')
async handleInitAwareness(
@MessageBody() workspaceId: string,
@ConnectedSocket() client: Socket
) {
this.metric.socketIOEventCounter(1, { event: 'awareness-init' });
const endTimer = this.metric.socketIOEventTimer({
event: 'init-awareness',
});
client.to(workspaceId).emit('new-client-awareness-init');
endTimer();
}
@SubscribeMessage('awareness-update')
async handleHelpGatheringAwareness(
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
@ConnectedSocket() client: Socket
) {
this.metric.socketIOEventCounter(1, { event: 'awareness-update' });
const endTimer = this.metric.socketIOEventTimer({
event: 'awareness-update',
});
client.to(message.workspaceId).emit('server-awareness-broadcast', {
...message,
});
endTimer();
return 'ack';
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DocModule } from '../../doc';
import { EventsGateway } from './events.gateway';
import { WorkspaceService } from './workspace';
@Module({
imports: [DocModule.forFeature()],
providers: [EventsGateway, WorkspaceService],
})
export class EventsModule {}
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { Doc, encodeStateAsUpdate } from 'yjs';
import { DocManager } from '../../doc';
import { assertExists } from '../utils';
@Injectable()
export class WorkspaceService {
constructor(private readonly docManager: DocManager) {}
async getDocsFromWorkspaceId(workspaceId: string): Promise<
Array<{
guid: string;
update: Buffer;
}>
> {
const docs: Array<{
guid: string;
update: Buffer;
}> = [];
const queue: Array<[string, Doc]> = [];
// Workspace Doc's guid is the same as workspaceId. This is achieved by when creating a new workspace, the doc guid
// is manually set to workspaceId.
const doc = await this.docManager.getLatest(workspaceId, workspaceId);
if (doc) {
queue.push([workspaceId, doc]);
}
while (queue.length > 0) {
const head = queue.pop();
assertExists(head);
const [guid, doc] = head;
docs.push({
guid: guid,
update: Buffer.from(encodeStateAsUpdate(doc)),
});
for (const { guid } of doc.subdocs) {
const subDoc = await this.docManager.getLatest(workspaceId, guid);
if (subDoc) {
queue.push([guid, subDoc]);
}
}
}
return docs;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EventsModule } from './events/events.module';
@Module({
imports: [EventsModule],
})
export class SyncModule {}
@@ -0,0 +1,37 @@
import { IoAdapter } from '@nestjs/platform-socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
export class RedisIoAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter> | undefined;
async connectToRedis(
host: string,
port: number,
username: string,
password: string,
db: number
): Promise<void> {
const pubClient = new Redis(port, host, {
username,
password,
db,
});
pubClient.on('error', err => {
console.error(err);
});
const subClient = pubClient.duplicate();
subClient.on('error', err => {
console.error(err);
});
this.adapterConstructor = createAdapter(pubClient, subClient);
}
override createIOServer(port: number, options?: ServerOptions): any {
const server = super.createIOServer(port, options);
server.adapter(this.adapterConstructor);
return server;
}
}
+11
View File
@@ -0,0 +1,11 @@
export function assertExists<T>(
val: T | null | undefined,
message: string | Error = 'val does not exist'
): asserts val is T {
if (val === null || val === undefined) {
if (message instanceof Error) {
throw message;
}
throw new Error(message);
}
}
+125 -8
View File
@@ -1,4 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
HttpException,
} from '@nestjs/common';
import {
Args,
Field,
@@ -6,16 +10,23 @@ import {
Mutation,
ObjectType,
Query,
registerEnumType,
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import type { FileUpload } from '../../types';
import { Auth } from '../auth/guard';
import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types';
registerEnumType(NewFeaturesKind, {
name: 'NewFeaturesKind',
});
@ObjectType()
export class UserType implements Partial<User> {
@@ -28,11 +39,34 @@ export class UserType implements Partial<User> {
@Field({ description: 'User email' })
email!: string;
@Field({ description: 'User avatar url', nullable: true })
avatarUrl!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl: string | null = null;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
}
@ObjectType()
export class DeleteAccount {
@Field()
success!: boolean;
}
@ObjectType()
export class AddToNewFeaturesWaitingList {
@Field()
email!: string;
@Field(() => NewFeaturesKind, { description: 'New features kind' })
type!: NewFeaturesKind;
}
@Auth()
@@ -40,17 +74,59 @@ export class UserType implements Partial<User> {
export class UserResolver {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService
private readonly storage: StorageService,
private readonly config: Config
) {}
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
})
async currentUser(@CurrentUser() user: User) {
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
hasPassword: !!user.password,
};
}
@Query(() => UserType, {
name: 'user',
description: 'Get user by email',
nullable: true,
})
@Public()
async user(@Args('email') email: string) {
return this.prisma.user.findUnique({
where: { email },
});
if (this.config.node.prod && this.config.affine.beta) {
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList
.findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.catch(() => false);
if (!hasEarlyAccess) {
return new HttpException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
401
);
}
}
// TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.prisma.user
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
if (user?.password) {
const userResponse: UserType = user;
userResponse.hasPassword = true;
}
return user;
}
@Mutation(() => UserType, {
@@ -72,4 +148,45 @@ export class UserResolver {
data: { avatarUrl: url },
});
}
@Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({
where: {
id: user.id,
},
});
await this.prisma.session.deleteMany({
where: {
userId: user.id,
},
});
return {
success: true,
};
}
@Mutation(() => AddToNewFeaturesWaitingList)
async addToNewFeaturesWaitingList(
@CurrentUser() user: UserType,
@Args('type', {
type: () => NewFeaturesKind,
})
type: NewFeaturesKind,
@Args('email') email: string
): Promise<AddToNewFeaturesWaitingList> {
if (!user.email.endsWith('@toeverything.info')) {
throw new ForbiddenException('You are not allowed to do this');
}
await this.prisma.newFeaturesWaitingList.create({
data: {
email,
type,
},
});
return {
email,
type,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export enum NewFeaturesKind {
EarlyAccess,
}
@@ -1,6 +1,7 @@
import type { Storage } from '@affine/storage';
import {
Controller,
ForbiddenException,
Get,
Inject,
NotFoundException,
@@ -8,20 +9,33 @@ import {
Res,
} from '@nestjs/common';
import type { Response } from 'express';
import format from 'pretty-time';
import { StorageProvide } from '../../storage';
import { trimGuid } from '../../utils/doc';
import { Auth, CurrentUser, Publicable } from '../auth';
import { DocManager } from '../doc';
import { UserType } from '../users';
import { PermissionService } from './permission';
@Controller('/api/workspaces')
export class WorkspacesController {
constructor(@Inject(StorageProvide) private readonly storage: Storage) {}
constructor(
@Inject(StorageProvide) private readonly storage: Storage,
private readonly permission: PermissionService,
private readonly docManager: DocManager
) {}
// get workspace blob
//
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
@Get('/:id/blobs/:name')
async blob(
@Param('id') workspaceId: string,
@Param('name') name: string,
@Res() res: Response
) {
const blob = await this.storage.blob(workspaceId, name);
const blob = await this.storage.getBlob(workspaceId, name);
if (!blob) {
throw new NotFoundException('Blob not found');
@@ -33,4 +47,34 @@ export class WorkspacesController {
res.send(blob.data);
}
// get doc binary
@Get('/:id/docs/:guid')
@Auth()
@Publicable()
async doc(
@CurrentUser() user: UserType | undefined,
@Param('id') ws: string,
@Param('guid') guid: string,
@Res() res: Response
) {
const start = process.hrtime();
const id = trimGuid(ws, guid);
if (
// if a user has the permission
!(await this.permission.isAccessible(ws, id, user?.id))
) {
throw new ForbiddenException('Permission denied');
}
const update = await this.docManager.getLatestUpdate(ws, id);
if (!update) {
throw new NotFoundException('Doc not found');
}
res.setHeader('content-type', 'application/octet-stream');
res.send(update);
console.info('workspaces doc api: ', format(process.hrtime(start)));
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { DocModule } from '../doc';
import { WorkspacesController } from './controller';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver, PermissionService, WorkspacesController],
imports: [DocModule.forFeature()],
controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { WorkspaceType } from './resolver';
export { InvitationType, WorkspaceType } from './resolver';
+132 -20
View File
@@ -12,6 +12,7 @@ export class PermissionService {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
@@ -20,6 +21,38 @@ export class PermissionService {
return data?.type as Permission;
}
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
if (user) {
return await this.tryCheck(ws, user);
} else {
// check if this is a public workspace
const count = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (count > 0) {
return true;
}
// check whether this is a public subpage
const workspace = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: ws,
userId: null,
},
});
const subpages = workspace
.map(ws => ws.subPageId)
.filter((v): v is string => !!v);
if (subpages.length > 0 && ws === id) {
// rootDoc is always accessible when there is a public subpage
return true;
} else {
// check if this is a public subpage
return subpages.map(page => `space:${page}`).includes(id);
}
}
}
async check(
ws: string,
user: string,
@@ -35,9 +68,21 @@ export class PermissionService {
user: string,
permission: Permission = Permission.Read
) {
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (data > 0) {
return true;
}
}
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
type: {
@@ -46,30 +91,18 @@ export class PermissionService {
},
});
if (data > 0) {
return true;
}
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
return data > 0;
}
return false;
return data > 0;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
): Promise<string> {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
@@ -105,22 +138,40 @@ export class PermissionService {
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p;
return p.id;
}
return this.prisma.userWorkspacePermission.create({
data: {
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: null,
userId: user,
type: permission,
},
})
.then(p => p.id);
}
async acceptById(ws: string, id: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
id,
workspaceId: ws,
userId: user,
type: permission,
},
data: {
accepted: true,
},
});
return result.count > 0;
}
async accept(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: false,
},
@@ -136,6 +187,67 @@ export class PermissionService {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
async isPageAccessible(ws: string, page: string, user?: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
return data?.accepted || false;
}
async grantPage(
ws: string,
page: string,
user?: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
if (data) {
return data.accepted;
}
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: page,
userId: user,
// if provide user id, user need to accept the invitation
accepted: user ? false : true,
type: permission,
},
})
.then(ret => ret.accepted);
}
async revokePage(ws: string, page: string, user?: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
+312 -40
View File
@@ -8,6 +8,7 @@ import {
Int,
Mutation,
ObjectType,
OmitType,
Parent,
PartialType,
PickType,
@@ -19,20 +20,43 @@ import {
import type { User, Workspace } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser } from '../auth';
import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
import { Permission } from './types';
import { defaultWorkspaceAvatar } from './utils';
registerEnumType(Permission, {
name: 'Permission',
description: 'User permission in workspace',
});
@ObjectType()
export class InviteUserType extends OmitType(
PartialType(UserType),
['id'],
ObjectType
) {
@Field(() => ID)
id!: string;
@Field(() => Permission, { description: 'User permission in workspace' })
permission!: Permission;
@Field({ description: 'Invite id' })
inviteId!: string;
@Field({ description: 'User accepted' })
accepted!: boolean;
}
@ObjectType()
export class WorkspaceType implements Partial<Workspace> {
@Field(() => ID)
@@ -43,6 +67,34 @@ export class WorkspaceType implements Partial<Workspace> {
@Field({ description: 'Workspace created date' })
createdAt!: Date;
@Field(() => [InviteUserType], {
description: 'Members of workspace',
})
members!: InviteUserType[];
}
@ObjectType()
export class InvitationWorkspaceType {
@Field(() => ID)
id!: string;
@Field({ description: 'Workspace name' })
name!: string;
@Field(() => String, {
// nullable: true,
description: 'Base64 encoded avatar',
})
avatar!: string;
}
@ObjectType()
export class InvitationType {
@Field({ description: 'Workspace information' })
workspace!: InvitationWorkspaceType;
@Field({ description: 'User information' })
user!: UserType;
}
@InputType()
@@ -59,6 +111,8 @@ export class UpdateWorkspaceInput extends PickType(
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(
private readonly auth: AuthService,
private readonly mailer: MailService,
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService,
@Inject(StorageProvide) private readonly storage: Storage
@@ -69,7 +123,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async permission(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
@@ -99,6 +153,20 @@ export class WorkspaceResolver {
});
}
@ResolveField(() => [String], {
description: 'Shared pages of workspace',
complexity: 2,
})
async sharedPages(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: workspace.id,
},
});
return data.map(item => item.subPageId).filter(Boolean);
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
@@ -117,27 +185,46 @@ export class WorkspaceResolver {
return data.user;
}
@ResolveField(() => [UserType], {
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
async members(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: workspace.id,
accepted: true,
userId: {
not: user.id,
},
},
include: {
user: true,
},
});
return data.map(({ user }) => user);
return data.map(({ id, accepted, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
}));
}
@Query(() => Boolean, {
description: 'Get is owner of workspace',
complexity: 2,
})
async isOwner(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
return data?.user?.id === user.id;
}
@Query(() => [WorkspaceType], {
@@ -163,6 +250,22 @@ export class WorkspaceResolver {
});
}
@Query(() => WorkspaceType, {
description: 'Get public workspace by id',
})
@Public()
async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({
where: { id },
});
if (workspace?.public) {
return workspace;
}
throw new NotFoundException("Workspace doesn't exist");
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
@@ -181,7 +284,7 @@ export class WorkspaceResolver {
description: 'Create a new workspace',
})
async createWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args({ name: 'init', type: () => GraphQLUpload })
update: FileUpload
) {
@@ -215,7 +318,13 @@ export class WorkspaceResolver {
},
});
await this.storage.createWorkspace(workspace.id, buffer);
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
return workspace;
}
@@ -224,11 +333,11 @@ export class WorkspaceResolver {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check('id', user.id, Permission.Admin);
await this.permissionProvider.check(id, user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
@@ -239,7 +348,7 @@ export class WorkspaceResolver {
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
@@ -248,25 +357,30 @@ export class WorkspaceResolver {
},
});
await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
await this.storage.deleteWorkspace(id);
await this.prisma.$transaction([
this.prisma.update.deleteMany({
where: {
workspaceId: id,
},
}),
this.prisma.snapshot.deleteMany({
where: {
workspaceId: id,
},
}),
]);
return true;
}
@Mutation(() => Boolean)
@Mutation(() => String)
async invite(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission
@Args('permission', { type: () => Permission }) permission: Permission,
// TODO: add rate limit
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
@@ -280,18 +394,122 @@ export class WorkspaceResolver {
},
});
if (!target) {
throw new NotFoundException("User doesn't exist");
if (target) {
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
if (originRecord) {
return originRecord.id;
}
const inviteId = await this.permissionProvider.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
}
return inviteId;
} else {
const user = await this.auth.createAnonymousUser(email);
const inviteId = await this.permissionProvider.grant(
workspaceId,
user.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
}
return inviteId;
}
}
@Public()
@Query(() => InvitationType, {
description: 'Update workspace',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
const permission =
await this.prisma.userWorkspacePermission.findUniqueOrThrow({
where: {
id: inviteId,
},
});
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
where: {
id: permission.workspaceId,
workspaceId: permission.workspaceId,
},
});
const doc = new Doc();
applyUpdate(doc, new Uint8Array(snapshot.blob));
const metaJSON = doc.getMap('meta').toJSON();
const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: permission.workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
let avatar = '';
if (metaJSON.avatar) {
const avatarBlob = await this.storage.getBlob(
permission.workspaceId,
metaJSON.avatar
);
avatar = avatarBlob?.data.toString('base64') || '';
}
await this.permissionProvider.grant(workspaceId, target.id, permission);
return true;
return {
workspace: {
name: metaJSON.name || '',
avatar: avatar || defaultWorkspaceAvatar,
id: permission.workspaceId,
},
user: owner.user,
};
}
@Mutation(() => Boolean)
async revoke(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
@@ -300,9 +518,18 @@ export class WorkspaceResolver {
return this.permissionProvider.revoke(workspaceId, userId);
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string
) {
return this.permissionProvider.acceptById(workspaceId, inviteId);
}
@Mutation(() => Boolean)
async acceptInvite(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
return this.permissionProvider.accept(workspaceId, user.id);
@@ -310,7 +537,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
@@ -318,14 +545,48 @@ export class WorkspaceResolver {
return this.permissionProvider.revoke(workspaceId, user.id);
}
@Mutation(() => Boolean)
async sharePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.grantPage(workspaceId, pageId);
}
@Mutation(() => Boolean)
async revokePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.revokePage(workspaceId, pageId);
}
@Query(() => [String], {
description: 'List blobs of workspace',
})
async listBlobs(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
return this.storage.listBlobs(workspaceId);
}
@Mutation(() => String)
async uploadBlob(
@CurrentUser() user: User,
async setBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -341,4 +602,15 @@ export class WorkspaceResolver {
return this.storage.uploadBlob(workspaceId, buffer);
}
@Mutation(() => Boolean)
async deleteBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('hash') hash: string
) {
await this.permissionProvider.check(workspaceId, user.id);
return this.storage.deleteBlob(workspaceId, hash);
}
}
@@ -0,0 +1,2 @@
export const defaultWorkspaceAvatar =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
+7 -8
View File
@@ -1,18 +1,17 @@
import type { INestApplication, OnModuleInit } from '@nestjs/common';
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
process.on('beforeExit', () => {
app.close().catch(e => {
console.error(e);
});
});
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+132 -77
View File
@@ -5,25 +5,23 @@
type UserType {
id: ID!
"""
User name
"""
"""User name"""
name: String!
"""
User email
"""
"""User email"""
email: String!
"""
User avatar url
"""
"""User avatar url"""
avatarUrl: String
"""
User created date
"""
"""User email verified"""
emailVerified: DateTime
"""User created date"""
createdAt: DateTime
"""User password has been set"""
hasPassword: Boolean
token: TokenType!
}
@@ -32,48 +30,57 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
"""
scalar DateTime
type DeleteAccount {
success: Boolean!
}
type AddToNewFeaturesWaitingList {
email: String!
"""New features kind"""
type: NewFeaturesKind!
}
enum NewFeaturesKind {
EarlyAccess
}
type TokenType {
token: String!
refresh: String!
}
type WorkspaceType {
type InviteUserType {
"""User name"""
name: String
"""User email"""
email: String
"""User avatar url"""
avatarUrl: String
"""User email verified"""
emailVerified: DateTime
"""User created date"""
createdAt: DateTime
"""User password has been set"""
hasPassword: Boolean
id: ID!
"""
is Public workspace
"""
public: Boolean!
"""
Workspace created date
"""
createdAt: DateTime!
"""
Permission of current signed in user in workspace
"""
"""User permission in workspace"""
permission: Permission!
"""
member count of workspace
"""
memberCount: Int!
"""Invite id"""
inviteId: String!
"""
Owner of workspace
"""
owner: UserType!
"""
Members of workspace
"""
members: [UserType!]!
"""User accepted"""
accepted: Boolean!
}
"""
User permission in workspace
"""
"""User permission in workspace"""
enum Permission {
Read
Write
@@ -81,62 +88,110 @@ enum Permission {
Owner
}
type WorkspaceType {
id: ID!
"""is Public workspace"""
public: Boolean!
"""Workspace created date"""
createdAt: DateTime!
"""Members of workspace"""
members: [InviteUserType!]!
"""Permission of current signed in user in workspace"""
permission: Permission!
"""member count of workspace"""
memberCount: Int!
"""Shared pages of workspace"""
sharedPages: [String!]!
"""Owner of workspace"""
owner: UserType!
}
type InvitationWorkspaceType {
id: ID!
"""Workspace name"""
name: String!
"""Base64 encoded avatar"""
avatar: String!
}
type InvitationType {
"""Workspace information"""
workspace: InvitationWorkspaceType!
"""User information"""
user: UserType!
}
type Query {
"""
Get all accessible workspaces for current user
"""
"""Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean!
"""Get all accessible workspaces for current user"""
workspaces: [WorkspaceType!]!
"""
Get workspace by id
"""
"""Get public workspace by id"""
publicWorkspace(id: String!): WorkspaceType!
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!
"""
Get user by email
"""
user(email: String!): UserType!
"""Update workspace"""
getInviteInfo(inviteId: String!): InvitationType!
"""List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]!
"""Get current user"""
currentUser: UserType!
"""Get user by email"""
user(email: String!): UserType
}
type Mutation {
register(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType!
"""
Create a new workspace
"""
"""Create a new workspace"""
createWorkspace(init: Upload!): WorkspaceType!
"""
Update workspace
"""
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
deleteWorkspace(id: String!): Boolean!
invite(
workspaceId: String!
email: String!
permission: Permission!
): Boolean!
invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String!
revoke(workspaceId: String!, userId: String!): Boolean!
acceptInviteById(workspaceId: String!, inviteId: String!): Boolean!
acceptInvite(workspaceId: String!): Boolean!
leaveWorkspace(workspaceId: String!): Boolean!
uploadBlob(workspaceId: String!, blob: Upload!): String!
sharePage(workspaceId: String!, pageId: String!): Boolean!
revokePage(workspaceId: String!, pageId: String!): Boolean!
setBlob(workspaceId: String!, blob: Upload!): String!
deleteBlob(workspaceId: String!, hash: String!): Boolean!
"""
Upload user avatar
"""
"""Upload user avatar"""
uploadAvatar(id: String!, avatar: Upload!): UserType!
deleteAccount: DeleteAccount!
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
signUp(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType!
changePassword(id: String!, newPassword: String!): UserType!
changeEmail(id: String!, email: String!): UserType!
sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendChangeEmail(email: String!, callbackUrl: String!): Boolean!
}
"""
The `Upload` scalar type represents a file upload.
"""
"""The `Upload` scalar type represents a file upload."""
scalar Upload
input UpdateWorkspaceInput {
"""
is Public workspace
"""
"""is Public workspace"""
public: Boolean
id: ID!
}
}
+10 -11
View File
@@ -1,28 +1,25 @@
import { createRequire } from 'node:module';
import type { Storage } from '@affine/storage';
import { type DynamicModule, type FactoryProvider } from '@nestjs/common';
import { Config } from '../config';
export const StorageProvide = Symbol('Storage');
const require = createRequire(import.meta.url);
let storageModule: typeof import('@affine/storage');
try {
storageModule = await import('@affine/storage');
} catch {
const require = createRequire(import.meta.url);
storageModule = require('../../storage.node');
}
export class StorageModule {
static forRoot(): DynamicModule {
const storageProvider: FactoryProvider = {
provide: StorageProvide,
useFactory: async (config: Config) => {
let StorageFactory: typeof Storage;
try {
// dev mode
StorageFactory = (await import('@affine/storage')).Storage;
} catch {
// In docker
StorageFactory = require('../../storage.node').Storage;
}
return StorageFactory.connect(config.db.url);
return storageModule.Storage.connect(config.db.url);
},
inject: [Config],
};
@@ -35,3 +32,5 @@ export class StorageModule {
};
}
}
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
+1
View File
@@ -30,6 +30,7 @@ describe('AppModule', () => {
password: await hash('123456'),
},
});
await client.$disconnect();
});
beforeEach(async () => {
+28 -13
View File
@@ -1,17 +1,19 @@
/// <reference types="../global.d.ts" />
import { ok } from 'node:assert';
import { beforeEach, test } from 'node:test';
import { equal } from 'node:assert';
import { afterEach, beforeEach, test } from 'node:test';
import { Test } from '@nestjs/testing';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { ConfigModule } from '../config';
import { GqlModule } from '../graphql.module';
import { MetricsModule } from '../metrics';
import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma';
let auth: AuthService;
let module: TestingModule;
// cleanup database before each test
beforeEach(async () => {
@@ -21,7 +23,7 @@ beforeEach(async () => {
});
beforeEach(async () => {
const module = await Test.createTestingModule({
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
@@ -33,37 +35,50 @@ beforeEach(async () => {
PrismaModule,
GqlModule,
AuthModule,
MetricsModule,
],
}).compile();
auth = module.get(AuthService);
});
afterEach(async () => {
await module.close();
});
test('should be able to register and signIn', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
});
test('should be able to verify', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
createdAt: new Date(),
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
{
const token = await auth.sign(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
equal(claim.id, '1');
equal(claim.name, 'Alex Yang');
equal(claim.email, 'alexyang@example.org');
equal(claim.emailVerified?.toISOString(), date.toISOString());
equal(claim.createdAt.toISOString(), date.toISOString());
}
{
const token = await auth.refresh(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
equal(claim.id, '1');
equal(claim.name, 'Alex Yang');
equal(claim.email, 'alexyang@example.org');
equal(claim.emailVerified?.toISOString(), date.toISOString());
equal(claim.createdAt.toISOString(), date.toISOString());
}
});
+158
View File
@@ -0,0 +1,158 @@
import { deepEqual, equal, ok } from 'node:assert';
import { afterEach, beforeEach, mock, test } from 'node:test';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { register } from 'prom-client';
import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { Config, ConfigModule } from '../config';
import { MetricsModule } from '../metrics';
import { DocManager, DocModule } from '../modules/doc';
import { PrismaModule, PrismaService } from '../prisma';
import { flushDB } from './utils';
const createModule = () => {
return Test.createTestingModule({
imports: [
PrismaModule,
MetricsModule,
ConfigModule.forRoot(),
DocModule.forRoot(),
],
}).compile();
};
test('Doc Module', async t => {
let app: INestApplication;
let m: TestingModule;
let timer: Sinon.SinonFakeTimers;
// cleanup database before each test
beforeEach(async () => {
timer = Sinon.useFakeTimers({
toFake: ['setInterval'],
});
await flushDB();
m = await createModule();
app = m.createNestApplication();
app.enableShutdownHooks();
await app.init();
});
afterEach(async () => {
await app.close();
timer.restore();
});
await t.test('should setup update poll interval', async () => {
register.clear();
const m = await createModule();
const manager = m.get(DocManager);
const fake = mock.method(manager, 'setup');
await m.createNestApplication().init();
equal(fake.mock.callCount(), 1);
// @ts-expect-error private member
ok(manager.job);
});
await t.test('should be able to stop poll', async () => {
const manager = m.get(DocManager);
const fake = mock.method(manager, 'destroy');
await app.close();
equal(fake.mock.callCount(), 1);
// @ts-expect-error private member
equal(manager.job, null);
});
await t.test('should poll when intervel due', async () => {
const manager = m.get(DocManager);
const interval = m.get(Config).doc.manager.updatePollInterval;
let resolve: any;
const fake = mock.method(manager, 'apply', () => {
return new Promise(_resolve => {
resolve = _resolve;
});
});
timer.tick(interval);
equal(fake.mock.callCount(), 1);
// busy
timer.tick(interval);
// @ts-expect-error private member
equal(manager.busy, true);
equal(fake.mock.callCount(), 1);
resolve();
await timer.tickAsync(1);
// @ts-expect-error private member
equal(manager.busy, false);
timer.tick(interval);
equal(fake.mock.callCount(), 2);
});
await t.test('should merge update when intervel due', async () => {
const db = m.get(PrismaService);
const manager = m.get(DocManager);
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
const ws = await db.workspace.create({
data: {
id: '1',
public: false,
},
});
await db.update.createMany({
data: [
{
id: '1',
workspaceId: '1',
blob: Buffer.from([0, 0]),
},
{
id: '1',
workspaceId: '1',
blob: Buffer.from(update),
},
],
});
await manager.apply();
deepEqual(await manager.getLatestUpdate(ws.id, '1'), update);
let appendUpdate = Buffer.from([]);
doc.on('update', update => {
appendUpdate = Buffer.from(update);
});
text.insert(5, 'world');
await db.update.create({
data: {
workspaceId: ws.id,
id: '1',
blob: appendUpdate,
},
});
await manager.apply();
deepEqual(
await manager.getLatestUpdate(ws.id, '1'),
encodeStateAsUpdate(doc)
);
});
});
+86
View File
@@ -0,0 +1,86 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../app';
import { MailService } from '../modules/auth/mailer';
import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils';
describe('Mail Module', () => {
let app: INestApplication;
const client = new PrismaClient();
let mail: MailService;
// cleanup database before each test
beforeEach(async () => {
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
mail = module.get(MailService);
});
afterEach(async () => {
await app.close();
});
it('should send invite email', async () => {
if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const inviteId = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const inviteInfo = await getInviteInfo(app, u1.token.token, inviteId);
const resp = await mail.sendInviteEmail(
'production@toeverything.info',
inviteId,
{
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: '',
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
}
);
ok(resp.accepted.length === 1, 'failed to send invite email');
}
});
});
@@ -0,0 +1,61 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, test } from 'node:test';
import { Test, TestingModule } from '@nestjs/testing';
import { register } from 'prom-client';
import { MetricsModule } from '../metrics';
import { Metrics } from '../metrics/metrics';
import { PrismaModule } from '../prisma';
let metrics: Metrics;
let module: TestingModule;
beforeEach(async () => {
module = await Test.createTestingModule({
imports: [MetricsModule, PrismaModule],
}).compile();
metrics = module.get(Metrics);
});
afterEach(async () => {
await module.close();
});
test('should be able to increment counter', async () => {
metrics.socketIOEventCounter(1, { event: 'client-handshake' });
const socketIOCounterMetric =
await register.getSingleMetric('socket_io_counter');
ok(socketIOCounterMetric);
ok(
JSON.stringify((await socketIOCounterMetric.get()).values) ===
'[{"value":1,"labels":{"event":"client-handshake"}}]'
);
});
test('should be able to timer', async () => {
const endTimer = metrics.socketIOEventTimer({ event: 'client-handshake' });
await new Promise(resolve => setTimeout(resolve, 50));
endTimer();
const endTimer2 = metrics.socketIOEventTimer({ event: 'client-handshake' });
await new Promise(resolve => setTimeout(resolve, 100));
endTimer2();
const socketIOTimerMetric = await register.getSingleMetric('socket_io_timer');
ok(socketIOTimerMetric);
const observations = (await socketIOTimerMetric.get()).values;
for (const observation of observations) {
if (
observation.labels.event === 'client-handshake' &&
'quantile' in observation.labels
) {
ok(observation.value >= 0.05);
ok(observation.value <= 0.15);
}
}
});
+77
View File
@@ -0,0 +1,77 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import { currentUser, signUp } from './utils';
describe('User Module', () => {
let app: INestApplication;
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('should register a user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
ok(typeof user.id === 'string', 'user.id is not a string');
ok(user.name === 'u1', 'user.name is not valid');
ok(user.email === 'u1@affine.pro', 'user.email is not valid');
});
it('should get current user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
const currUser = await currentUser(app, user.token.token);
ok(currUser.id === user.id, 'user.id is not valid');
ok(currUser.name === user.name, 'user.name is not valid');
ok(currUser.email === user.email, 'user.email is not valid');
ok(currUser.hasPassword, 'currUser.hasPassword is not valid');
});
it('should be able to delete user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
await request(app.getHttpServer())
.post('/graphql')
.auth(user.token.token, { type: 'bearer' })
.send({
query: `
mutation {
deleteAccount {
success
}
}
`,
})
.expect(200);
const current = await currentUser(app, user.token.token);
ok(current == null);
});
});
+465
View File
@@ -0,0 +1,465 @@
import type { INestApplication, LoggerService } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import type { TokenType } from '../modules/auth';
import type { UserType } from '../modules/users';
import type { InvitationType, WorkspaceType } from '../modules/workspaces';
export class NestDebugLogger implements LoggerService {
log(message: string): any {
console.log(message);
}
error(message: string, trace: string): any {
console.error(message, trace);
}
warn(message: string): any {
console.warn(message);
}
debug(message: string): any {
console.debug(message);
}
verbose(message: string): any {
console.log(message);
}
}
const gql = '/graphql';
async function signUp(
app: INestApplication,
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
signUp(name: "${name}", email: "${email}", password: "${password}") {
id, name, email, token { token }
}
}
`,
})
.expect(200);
return res.body.data.signUp;
}
async function currentUser(app: INestApplication, token: string) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
currentUser {
id, name, email, emailVerified, avatarUrl, createdAt, hasPassword
}
}
`,
})
.expect(200);
return res.body?.data?.currentUser;
}
async function createWorkspace(
app: INestApplication,
token: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
export async function getWorkspaceSharedPages(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
sharedPages
}
}
`,
})
.expect(200);
return res.body.data.workspace.sharedPages;
}
async function getWorkspace(
app: INestApplication,
token: string,
workspaceId: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
id, members { id, name, email, permission, inviteId }
}
}
`,
})
.expect(200);
return res.body.data.workspace;
}
async function getPublicWorkspace(
app: INestApplication,
workspaceId: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
publicWorkspace(id: "${workspaceId}") {
id
}
}
`,
})
.expect(200);
return res.body.data.publicWorkspace;
}
async function updateWorkspace(
app: INestApplication,
token: string,
workspaceId: string,
isPublic: boolean
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
public
}
}
`,
})
.expect(200);
return res.body.data.updateWorkspace.public;
}
async function inviteUser(
app: INestApplication,
token: string,
workspaceId: string,
email: string,
permission: string,
sendInviteMail = false
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail})
}
`,
})
.expect(200);
return res.body.data.invite;
}
async function acceptInviteById(
app: INestApplication,
workspaceId: string,
inviteId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}")
}
`,
})
.expect(200);
return res.body.data.acceptInviteById;
}
async function acceptInvite(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInvite(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.acceptInvite;
}
async function leaveWorkspace(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.leaveWorkspace;
}
async function revokeUser(
app: INestApplication,
token: string,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
return res.body.data.revoke;
}
async function sharePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sharePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.sharePage;
}
async function revokePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.revokePage;
}
async function listBlobs(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
listBlobs(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.listBlobs;
}
async function setBlob(
app: INestApplication,
token: string,
workspaceId: string,
buffer: Buffer
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'setBlob',
query: `mutation setBlob($blob: Upload!) {
setBlob(workspaceId: "${workspaceId}", blob: $blob)
}`,
variables: { blob: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.blob'] }))
.attach('0', buffer, 'blob.data')
.expect(200);
return res.body.data.setBlob;
}
async function flushDB() {
const client = new PrismaClient();
await client.$connect();
const result: { tablename: string }[] =
await client.$queryRaw`SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'`;
// remove all table data
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${result
.map(({ tablename }) => tablename)
.filter(name => !name.includes('migrations'))
.join(', ')}`
);
await client.$disconnect();
}
async function createTestApp() {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
return app;
}
async function getInviteInfo(
app: INestApplication,
token: string,
inviteId: string
): Promise<InvitationType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
getInviteInfo(inviteId: "${inviteId}") {
workspace {
id
name
avatar
}
user {
id
name
avatarUrl
}
}
}
`,
})
.expect(200);
return res.body.data.workspace;
}
export {
acceptInvite,
acceptInviteById,
createTestApp,
createWorkspace,
currentUser,
flushDB,
getInviteInfo,
getPublicWorkspace,
getWorkspace,
inviteUser,
leaveWorkspace,
listBlobs,
revokePage,
revokeUser,
setBlob,
sharePage,
signUp,
updateWorkspace,
};
@@ -0,0 +1,70 @@
import { deepEqual, ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import { createWorkspace, listBlobs, setBlob, signUp } from './utils';
describe('Workspace Module - Blobs', () => {
let app: INestApplication;
const client = new PrismaClient();
// cleanup database before each test
beforeEach(async () => {
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('should list blobs', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const blobs = await listBlobs(app, u1.token.token, workspace.id);
ok(blobs.length === 0, 'failed to list blobs');
const buffer = Buffer.from([0, 0]);
const hash = await setBlob(app, u1.token.token, workspace.id, buffer);
const ret = await listBlobs(app, u1.token.token, workspace.id);
ok(ret.length === 1, 'failed to list blobs');
ok(ret[0] === hash, 'failed to list blobs');
const server = app.getHttpServer();
const token = u1.token.token;
const response = await request(server)
.get(`/api/workspaces/${workspace.id}/blobs/${hash}`)
.auth(token, { type: 'bearer' })
.buffer();
deepEqual(response.body, buffer, 'failed to get blob');
});
});
@@ -0,0 +1,189 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../app';
import { MailService } from '../modules/auth/mailer';
import { AuthService } from '../modules/auth/service';
import {
acceptInvite,
acceptInviteById,
createWorkspace,
getWorkspace,
inviteUser,
leaveWorkspace,
revokeUser,
signUp,
} from './utils';
describe('Workspace Module - invite', () => {
let app: INestApplication;
const client = new PrismaClient();
let auth: AuthService;
let mail: MailService;
// cleanup database before each test
beforeEach(async () => {
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
auth = module.get(AuthService);
mail = module.get(MailService);
});
afterEach(async () => {
await app.close();
});
it('should invite a user', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
ok(!!invite, 'failed to invite user');
});
it('should accept an invite', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
const accept = await acceptInvite(app, u2.token.token, workspace.id);
ok(accept === true, 'failed to accept invite');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
const currMember = currWorkspace.members.find(u => u.email === u2.email);
ok(currMember !== undefined, 'failed to invite user');
ok(currMember.id === u2.id, 'failed to invite user');
ok(!currMember.accepted, 'failed to invite user');
});
it('should leave a workspace', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
const leave = await leaveWorkspace(app, u2.token.token, workspace.id);
ok(leave === true, 'failed to leave workspace');
});
it('should revoke a user', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
ok(currWorkspace.members.length === 2, 'failed to invite user');
const revoke = await revokeUser(app, u1.token.token, workspace.id, u2.id);
ok(revoke === true, 'failed to revoke user');
});
it('should create user if not exist', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(
app,
u1.token.token,
workspace.id,
'u2@affine.pro',
'Admin'
);
const user = await auth.getUserByEmail('u2@affine.pro');
ok(user !== undefined, 'failed to create user');
ok(user?.name === 'Unnamed', 'failed to create user');
});
it('should invite a user by link', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const accept = await acceptInviteById(app, workspace.id, invite);
ok(accept === true, 'failed to accept invite');
const invite1 = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
ok(invite === invite1, 'repeat the invitation must return same id');
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
const currMember = currWorkspace.members.find(u => u.email === u2.email);
ok(currMember !== undefined, 'failed to invite user');
ok(currMember.inviteId === invite, 'failed to check invite id');
});
it('should send invite email', async () => {
if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'test', 'production@toeverything.info', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin',
true
);
}
});
});
+163 -159
View File
@@ -1,4 +1,4 @@
import { ok } from 'node:assert';
import { deepEqual, ok, rejects } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
@@ -9,20 +9,30 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import type { TokenType } from '../modules/auth';
import type { UserType } from '../modules/users';
import type { WorkspaceType } from '../modules/workspaces';
const gql = '/graphql';
import {
acceptInvite,
createWorkspace,
getPublicWorkspace,
getWorkspaceSharedPages,
inviteUser,
revokePage,
sharePage,
signUp,
updateWorkspace,
} from './utils';
describe('Workspace Module', () => {
let app: INestApplication;
const client = new PrismaClient();
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.update.deleteMany({});
await client.snapshot.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
@@ -44,183 +54,177 @@ describe('Workspace Module', () => {
await app.close();
});
async function registerUser(
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
register(name: "${name}", email: "${email}", password: "${password}") {
id, name, email, token { token }
}
}
`,
})
.expect(200);
return res.body.data.register;
}
async function createWorkspace(token: string): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
async function inviteUser(
token: string,
workspaceId: string,
email: string,
permission: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission})
}
`,
})
.expect(200);
return res.body.data.invite;
}
async function acceptInvite(
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
acceptInvite(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.acceptInvite;
}
async function leaveWorkspace(
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.leaveWorkspace;
}
async function revokeUser(
token: string,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
return res.body.data.revoke;
}
it('should register a user', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '123456');
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
ok(typeof user.id === 'string', 'user.id is not a string');
ok(user.name === 'u1', 'user.name is not valid');
ok(user.email === 'u1@affine.pro', 'user.email is not valid');
});
it('should create a workspace', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '1');
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(user.token.token);
const workspace = await createWorkspace(app, user.token.token);
ok(typeof workspace.id === 'string', 'workspace.id is not a string');
});
it('should invite a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can publish workspace', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const workspace = await createWorkspace(u1.token.token);
const invite = await inviteUser(
u1.token.token,
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
u2.email,
'Admin'
true
);
ok(invite === true, 'failed to invite user');
ok(isPublic === true, 'failed to publish workspace');
const isPrivate = await updateWorkspace(
app,
user.token.token,
workspace.id,
false
);
ok(isPrivate === false, 'failed to unpublish workspace');
});
it('should accept an invite', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can read published workspace', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
rejects(
getPublicWorkspace(app, 'not_exists_ws'),
'must not get not exists workspace'
);
rejects(
getPublicWorkspace(app, workspace.id),
'must not get private workspace'
);
const accept = await acceptInvite(u2.token.token, workspace.id);
ok(accept === true, 'failed to accept invite');
await updateWorkspace(app, user.token.token, workspace.id, true);
const publicWorkspace = await getPublicWorkspace(app, workspace.id);
ok(publicWorkspace.id === workspace.id, 'failed to get public workspace');
});
it('should leave a workspace', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should share a page', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(u2.token.token, workspace.id);
const workspace = await createWorkspace(app, u1.token.token);
const leave = await leaveWorkspace(u2.token.token, workspace.id);
ok(leave === true, 'failed to leave workspace');
const share = await sharePage(app, u1.token.token, workspace.id, 'page1');
ok(share === true, 'failed to share page');
const pages = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(pages.length === 1, 'failed to get shared pages');
ok(pages[0] === 'page1', 'failed to get shared page: page1');
const msg1 = await sharePage(app, u2.token.token, workspace.id, 'page2');
ok(msg1 === 'Permission denied', 'unauthorized user can share page');
const msg2 = await revokePage(
app,
u2.token.token,
'not_exists_ws',
'page2'
);
ok(msg2 === 'Permission denied', 'unauthorized user can share page');
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
const invited = await sharePage(app, u2.token.token, workspace.id, 'page2');
ok(invited === true, 'failed to share page');
const revoke = await revokePage(app, u1.token.token, workspace.id, 'page1');
ok(revoke === true, 'failed to revoke page');
const pages2 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(pages2.length === 1, 'failed to get shared pages');
ok(pages2[0] === 'page2', 'failed to get shared page: page2');
const msg3 = await revokePage(app, u1.token.token, workspace.id, 'page3');
ok(msg3 === false, 'can revoke non-exists page');
const msg4 = await revokePage(app, u1.token.token, workspace.id, 'page2');
ok(msg4 === true, 'failed to revoke page');
const page3 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(page3.length === 0, 'failed to get shared pages');
});
it('should revoke a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can get workspace doc', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '2');
const workspace = await createWorkspace(app, u1.token.token);
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
const res1 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u1.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
const revoke = await revokeUser(u1.token.token, workspace.id, u2.id);
ok(revoke === true, 'failed to revoke user');
deepEqual(
res1.body,
Buffer.from([0, 0]),
'failed to get doc with u1 token'
);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(403);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await acceptInvite(app, u2.token.token, workspace.id);
const res2 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
deepEqual(
res2.body,
Buffer.from([0, 0]),
'failed to get doc with u2 token'
);
});
it('should be able to get public workspace doc', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
true
);
ok(isPublic === true, 'failed to publish workspace');
const res = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(200)
.type('application/octet-stream');
deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
});
});
+6
View File
@@ -6,3 +6,9 @@ export interface FileUpload {
encoding: string;
createReadStream: () => Readable;
}
export interface ReqContext {
req: Express.Request & {
res: Express.Response;
};
}
+7
View File
@@ -0,0 +1,7 @@
export function trimGuid(ws: string, guid: string) {
if (guid.startsWith(`${ws}:space:`)) {
return guid.substring(ws.length + 1);
}
return guid;
}
+1
View File
@@ -7,6 +7,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"isolatedModules": false,
"resolveJsonModule": true,
"types": ["node"],