mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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,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
apps/server/src/constants.ts
Normal file
3
apps/server/src/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const OPERATION_NAME = 'x-operation-name';
|
||||
|
||||
export const REQUEST_ID = 'x-request-id';
|
||||
@@ -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
apps/server/src/graphql/logger-plugin.ts
Normal file
60
apps/server/src/graphql/logger-plugin.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
apps/server/src/metrics/controller.ts
Normal file
18
apps/server/src/metrics/controller.ts
Normal 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
apps/server/src/metrics/index.ts
Normal file
12
apps/server/src/metrics/index.ts
Normal 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
apps/server/src/metrics/metrics.ts
Normal file
25
apps/server/src/metrics/metrics.ts
Normal 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
apps/server/src/metrics/utils.ts
Normal file
73
apps/server/src/metrics/utils.ts
Normal 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
apps/server/src/middleware/timing.ts
Normal file
27
apps/server/src/middleware/timing.ts
Normal 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();
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
2
apps/server/src/modules/auth/mailer/index.ts
Normal file
2
apps/server/src/modules/auth/mailer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MailService } from './mail.service';
|
||||
export { MAILER } from './mailer';
|
||||
130
apps/server/src/modules/auth/mailer/mail.service.ts
Normal file
130
apps/server/src/modules/auth/mailer/mail.service.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
27
apps/server/src/modules/auth/mailer/mailer.ts
Normal file
27
apps/server/src/modules/auth/mailer/mailer.ts
Normal file
@@ -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],
|
||||
};
|
||||
195
apps/server/src/modules/auth/mailer/template.ts
Normal file
195
apps/server/src/modules/auth/mailer/template.ts
Normal file
@@ -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>`;
|
||||
};
|
||||
501
apps/server/src/modules/auth/next-auth-options.ts
Normal file
501
apps/server/src/modules/auth/next-auth-options.ts
Normal file
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
apps/server/src/modules/doc/index.ts
Normal file
42
apps/server/src/modules/doc/index.ts
Normal 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
apps/server/src/modules/doc/manager.ts
Normal file
351
apps/server/src/modules/doc/manager.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
150
apps/server/src/modules/doc/redis-manager.ts
Normal file
150
apps/server/src/modules/doc/redis-manager.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
153
apps/server/src/modules/sync/events/events.gateway.ts
Normal file
153
apps/server/src/modules/sync/events/events.gateway.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
11
apps/server/src/modules/sync/events/events.module.ts
Normal file
11
apps/server/src/modules/sync/events/events.module.ts
Normal file
@@ -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 {}
|
||||
48
apps/server/src/modules/sync/events/workspace.ts
Normal file
48
apps/server/src/modules/sync/events/workspace.ts
Normal file
@@ -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
apps/server/src/modules/sync/index.ts
Normal file
8
apps/server/src/modules/sync/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EventsModule } from './events/events.module';
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule],
|
||||
})
|
||||
export class SyncModule {}
|
||||
37
apps/server/src/modules/sync/redis-adapter.ts
Normal file
37
apps/server/src/modules/sync/redis-adapter.ts
Normal file
@@ -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
apps/server/src/modules/sync/utils.ts
Normal file
11
apps/server/src/modules/sync/utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
apps/server/src/modules/users/types.ts
Normal file
3
apps/server/src/modules/users/types.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/server/src/modules/workspaces/utils.ts
Normal file
2
apps/server/src/modules/workspaces/utils.ts
Normal file
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ describe('AppModule', () => {
|
||||
password: await hash('123456'),
|
||||
},
|
||||
});
|
||||
await client.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -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
apps/server/src/tests/doc.spec.ts
Normal file
158
apps/server/src/tests/doc.spec.ts
Normal 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
apps/server/src/tests/mailer.spec.ts
Normal file
86
apps/server/src/tests/mailer.spec.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
61
apps/server/src/tests/prometheus-metrics.spec.ts
Normal file
61
apps/server/src/tests/prometheus-metrics.spec.ts
Normal file
@@ -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
apps/server/src/tests/user.spec.ts
Normal file
77
apps/server/src/tests/user.spec.ts
Normal 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
apps/server/src/tests/utils.ts
Normal file
465
apps/server/src/tests/utils.ts
Normal 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,
|
||||
};
|
||||
70
apps/server/src/tests/workspace-blobs.spec.ts
Normal file
70
apps/server/src/tests/workspace-blobs.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
189
apps/server/src/tests/workspace-invite.spec.ts
Normal file
189
apps/server/src/tests/workspace-invite.spec.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,3 +6,9 @@ export interface FileUpload {
|
||||
encoding: string;
|
||||
createReadStream: () => Readable;
|
||||
}
|
||||
|
||||
export interface ReqContext {
|
||||
req: Express.Request & {
|
||||
res: Express.Response;
|
||||
};
|
||||
}
|
||||
|
||||
7
apps/server/src/utils/doc.ts
Normal file
7
apps/server/src/utils/doc.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user