feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { ConfigModule } from './config';
import { GqlModule } from './graphql.module';
import { MetricsModule } from './metrics';
import { BusinessModules } from './modules';
import { PrismaModule } from './prisma';
import { StorageModule } from './storage';
@@ -10,9 +10,9 @@ import { StorageModule } from './storage';
@Module({
imports: [
PrismaModule,
GqlModule,
ConfigModule.forRoot(),
StorageModule.forRoot(),
MetricsModule,
...BusinessModules,
],
controllers: [AppController],

View File

@@ -77,6 +77,10 @@ export interface AFFiNEConfig {
* System version
*/
readonly version: string;
/**
* Deployment environment
*/
readonly affineEnv: 'dev' | 'beta' | 'production';
/**
* alias to `process.env.NODE_ENV`
*
@@ -84,12 +88,22 @@ export interface AFFiNEConfig {
* @env NODE_ENV
*/
readonly env: string;
/**
* fast AFFiNE environment judge
*/
get affine(): {
canary: boolean;
beta: boolean;
stable: boolean;
};
/**
* fast environment judge
*/
get prod(): boolean;
get dev(): boolean;
get test(): boolean;
get node(): {
prod: boolean;
dev: boolean;
test: boolean;
};
get deploy(): boolean;
/**
@@ -167,6 +181,28 @@ export interface AFFiNEConfig {
path: string;
};
};
/**
* Redis Config
*
* whether to use redis as Socket.IO adapter
*/
redis: {
/**
* if not enabled, use in-memory adapter by default
*/
enabled: boolean;
/**
* url of redis host
*/
host: string;
/**
* port of redis
*/
port: number;
username: string;
password: string;
database: number;
};
/**
* authentication config
@@ -236,8 +272,30 @@ export interface AFFiNEConfig {
email: {
server: string;
port: number;
login: string;
sender: string;
password: string;
};
};
doc: {
manager: {
/**
* How often the [DocManager] will start a new turn of merging pending updates into doc snapshot.
*
* This is not the latency a new joint client will take to see the latest doc,
* but the buffer time we introduced to reduce the load of our service.
*
* in {ms}
*/
updatePollInterval: number;
/**
* Use JwstCodec to merge updates at the same time when merging using Yjs.
*
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
*/
experimentalMergeWithJwstCodec: boolean;
};
};
}

View File

@@ -51,37 +51,60 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
serverId: 'affine-nestjs-server',
version: pkg.version,
ENV_MAP: {
AFFINE_SERVER_PORT: 'port',
AFFINE_SERVER_PORT: ['port', 'int'],
AFFINE_SERVER_HOST: 'host',
AFFINE_SERVER_SUB_PATH: 'path',
AFFINE_ENV: 'affineEnv',
DATABASE_URL: 'db.url',
AUTH_PRIVATE_KEY: 'auth.privateKey',
ENABLE_R2_OBJECT_STORAGE: 'objectStorage.r2.enabled',
ENABLE_R2_OBJECT_STORAGE: ['objectStorage.r2.enabled', 'boolean'],
R2_OBJECT_STORAGE_ACCOUNT_ID: 'objectStorage.r2.accountId',
R2_OBJECT_STORAGE_ACCESS_KEY_ID: 'objectStorage.r2.accessKeyId',
R2_OBJECT_STORAGE_SECRET_ACCESS_KEY: 'objectStorage.r2.secretAccessKey',
R2_OBJECT_STORAGE_BUCKET: 'objectStorage.r2.bucket',
OAUTH_GOOGLE_ENABLED: ['auth.oauthProviders.google.enabled', 'boolean'],
OAUTH_GOOGLE_CLIENT_ID: 'auth.oauthProviders.google.clientId',
OAUTH_GOOGLE_CLIENT_SECRET: 'auth.oauthProviders.google.clientSecret',
OAUTH_GITHUB_ENABLED: ['auth.oauthProviders.github.enabled', 'boolean'],
OAUTH_GITHUB_CLIENT_ID: 'auth.oauthProviders.github.clientId',
OAUTH_GITHUB_CLIENT_SECRET: 'auth.oauthProviders.github.clientSecret',
OAUTH_EMAIL_LOGIN: 'auth.email.login',
OAUTH_EMAIL_SENDER: 'auth.email.sender',
OAUTH_EMAIL_SERVER: 'auth.email.server',
OAUTH_EMAIL_PORT: 'auth.email.port',
OAUTH_EMAIL_PORT: ['auth.email.port', 'int'],
OAUTH_EMAIL_PASSWORD: 'auth.email.password',
REDIS_SERVER_ENABLED: ['redis.enabled', 'boolean'],
REDIS_SERVER_HOST: 'redis.host',
REDIS_SERVER_PORT: ['redis.port', 'int'],
REDIS_SERVER_USER: 'redis.username',
REDIS_SERVER_PASSWORD: 'redis.password',
REDIS_SERVER_DATABASE: ['redis.database', 'int'],
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
DOC_MERGE_USE_JWST_CODEC: [
'doc.manager.experimentalMergeWithJwstCodec',
'boolean',
],
} satisfies AFFiNEConfig['ENV_MAP'],
affineEnv: 'dev',
get affine() {
const env = this.affineEnv;
return {
canary: env === 'dev',
beta: env === 'beta',
stable: env === 'production',
};
},
env: process.env.NODE_ENV ?? 'development',
get prod() {
return this.env === 'production';
},
get dev() {
return this.env === 'development';
},
get test() {
return this.env === 'test';
get node() {
const env = this.env;
return {
prod: env === 'production',
dev: env === 'development',
test: env === 'test',
};
},
get deploy() {
return !this.dev && !this.test;
return !this.node.dev && !this.node.test;
},
https: false,
host: 'localhost',
@@ -91,7 +114,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
url: '',
},
get origin() {
return this.dev
return this.node.dev
? 'http://localhost:8080'
: `${this.https ? 'https' : 'http'}://${this.host}${
this.host === 'localhost' ? `:${this.port}` : ''
@@ -124,6 +147,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
email: {
server: 'smtp.gmail.com',
port: 465,
login: '',
sender: '',
password: '',
},
@@ -140,6 +164,20 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
path: join(homedir(), '.affine-storage'),
},
},
redis: {
enabled: false,
host: '127.0.0.1',
port: 6379,
username: '',
password: '',
database: 0,
},
doc: {
manager: {
updatePollInterval: 3000,
experimentalMergeWithJwstCodec: false,
},
},
} satisfies AFFiNEConfig;
applyEnvToConfig(defaultConfig);

View File

@@ -0,0 +1,3 @@
export const OPERATION_NAME = 'x-operation-name';
export const REQUEST_ID = 'x-request-id';

View File

@@ -2,17 +2,20 @@ import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { Request, Response } from 'express';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { Config } from './config';
import { GQLLoggerPlugin } from './graphql/logger-plugin';
import { Metrics } from './metrics/metrics';
@Global()
@Module({
imports: [
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: Config) => {
useFactory: (config: Config, metrics: Metrics) => {
return {
...config.graphql,
path: `${config.path}/graphql`,
@@ -24,9 +27,14 @@ import { Config } from './config';
'..',
'schema.gql'
),
context: ({ req, res }: { req: Request; res: Response }) => ({
req,
res,
}),
plugins: [new GQLLoggerPlugin(metrics)],
};
},
inject: [Config],
inject: [Config, Metrics],
}),
],
})

View File

@@ -0,0 +1,60 @@
import {
ApolloServerPlugin,
GraphQLRequestContext,
GraphQLRequestListener,
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { Logger } from '@nestjs/common';
import { Response } from 'express';
import { OPERATION_NAME, REQUEST_ID } from '../constants';
import { Metrics } from '../metrics/metrics';
import { ReqContext } from '../types';
@Plugin()
export class GQLLoggerPlugin implements ApolloServerPlugin {
protected logger = new Logger(GQLLoggerPlugin.name);
constructor(private readonly metrics: Metrics) {}
requestDidStart(
reqContext: GraphQLRequestContext<ReqContext>
): Promise<GraphQLRequestListener<GraphQLRequestContext<ReqContext>>> {
const res = reqContext.contextValue.req.res as Response;
const operation = reqContext.request.operationName;
const headers = reqContext.request.http?.headers;
const requestId = headers
? headers.get(`${REQUEST_ID}`)
: 'Unknown Request ID';
const operationName = headers
? headers.get(`${OPERATION_NAME}`)
: 'Unknown Operation Name';
this.metrics.gqlRequest(1, { operation });
const timer = this.metrics.gqlTimer({ operation });
const requestInfo = `${REQUEST_ID}: ${requestId}, ${OPERATION_NAME}: ${operationName}`;
return Promise.resolve({
willSendResponse: () => {
const costInMilliseconds = timer() * 1000;
res.setHeader(
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL"`
);
this.logger.log(requestInfo);
return Promise.resolve();
},
didEncounterErrors: () => {
this.metrics.gqlError(1, { operation });
const costInMilliseconds = timer() * 1000;
res.setHeader(
'Server-Timing',
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
);
this.logger.error(`${requestInfo}, query: ${reqContext.request.query}`);
return Promise.resolve();
},
});
}
}

View File

@@ -1,6 +1,22 @@
/// <reference types="./global.d.ts" />
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
CompositePropagator,
W3CBaggagePropagator,
W3CTraceContextPropagator,
} from '@opentelemetry/core';
import gql from '@opentelemetry/instrumentation-graphql';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import ioredis from '@opentelemetry/instrumentation-ioredis';
import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import socketIO from '@opentelemetry/instrumentation-socket.io';
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node';
import { PrismaInstrumentation } from '@prisma/instrumentation';
import cookieParser from 'cookie-parser';
import { static as staticMiddleware } from 'express';
// @ts-expect-error graphql-upload is not typed
@@ -8,19 +24,47 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from './app';
import { Config } from './config';
import { serverTimingAndCache } from './middleware/timing';
import { RedisIoAdapter } from './modules/sync/redis-adapter';
const { NODE_ENV } = process.env;
if (NODE_ENV === 'production') {
const traceExporter = new TraceExporter();
const tracing = new NodeSDK({
traceExporter,
metricReader: new PeriodicExportingMetricReader({
exporter: new MetricExporter(),
}),
spanProcessor: new BatchSpanProcessor(traceExporter),
textMapPropagator: new CompositePropagator({
propagators: [
new W3CBaggagePropagator(),
new W3CTraceContextPropagator(),
],
}),
instrumentations: [
new NestInstrumentation(),
new ioredis.IORedisInstrumentation(),
new socketIO.SocketIoInstrumentation({ traceReserved: true }),
new gql.GraphQLInstrumentation({ mergeItems: true }),
new HttpInstrumentation(),
new PrismaInstrumentation(),
],
serviceName: 'affine-cloud',
});
tracing.start();
}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: {
origin:
process.env.AFFINE_ENV === 'preview'
? ['https://affine-preview.vercel.app']
: ['http://localhost:8080'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['x-operation-name', 'x-definition-name'],
},
cors: true,
bodyParser: true,
logger: NODE_ENV === 'production' ? ['log'] : ['verbose'],
});
app.use(serverTimingAndCache);
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
@@ -39,6 +83,18 @@ if (!config.objectStorage.r2.enabled) {
app.use('/assets', staticMiddleware(config.objectStorage.fs.path));
}
if (config.redis.enabled) {
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis(
config.redis.host,
config.redis.port,
config.redis.username,
config.redis.password,
config.redis.database
);
app.useWebSocketAdapter(redisIoAdapter);
}
await app.listen(port, host);
console.log(`Listening on http://${host}:${port}`);

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Res } from '@nestjs/common';
import type { Response } from 'express';
import { register } from 'prom-client';
import { PrismaService } from '../prisma';
@Controller()
export class MetricsController {
constructor(private readonly prisma: PrismaService) {}
@Get('/metrics')
async index(@Res() res: Response): Promise<void> {
res.header('Content-Type', register.contentType);
const prismaMetrics = await this.prisma.$metrics.prometheus();
const appMetrics = await register.metrics();
res.send(appMetrics + prismaMetrics);
}
}

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { MetricsController } from '../metrics/controller';
import { Metrics } from './metrics';
@Global()
@Module({
providers: [Metrics],
exports: [Metrics],
controllers: [MetricsController],
})
export class MetricsModule {}

View File

@@ -0,0 +1,25 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { register } from 'prom-client';
import { metricsCreator } from './utils';
@Injectable()
export class Metrics implements OnModuleDestroy {
onModuleDestroy(): void {
register.clear();
}
socketIOEventCounter = metricsCreator.counter('socket_io_counter', ['event']);
socketIOEventTimer = metricsCreator.timer('socket_io_timer', ['event']);
socketIOConnectionGauge = metricsCreator.gauge(
'socket_io_connection_counter'
);
gqlRequest = metricsCreator.counter('gql_request', ['operation']);
gqlError = metricsCreator.counter('gql_error', ['operation']);
gqlTimer = metricsCreator.timer('gql_timer', ['operation']);
jwstCodecMerge = metricsCreator.counter('jwst_codec_merge');
jwstCodecDidnotMatch = metricsCreator.counter('jwst_codec_didnot_match');
jwstCodecFail = metricsCreator.counter('jwst_codec_fail');
}

View File

@@ -0,0 +1,73 @@
import { Counter, Gauge, Summary } from 'prom-client';
type LabelValues<T extends string> = Partial<Record<T, string | number>>;
type MetricsCreator<T extends string> = (
value: number,
labels: LabelValues<T>
) => void;
type TimerMetricsCreator<T extends string> = (
labels: LabelValues<T>
) => () => number;
export const metricsCreatorGenerator = () => {
const counterCreator = <T extends string>(
name: string,
labelNames?: T[]
): MetricsCreator<T> => {
const counter = new Counter({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (value: number, labels: LabelValues<T>) => {
counter.inc(labels, value);
};
};
const gaugeCreator = <T extends string>(
name: string,
labelNames?: T[]
): MetricsCreator<T> => {
const gauge = new Gauge({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (value: number, labels: LabelValues<T>) => {
gauge.set(labels, value);
};
};
const timerCreator = <T extends string>(
name: string,
labelNames?: T[]
): TimerMetricsCreator<T> => {
const summary = new Summary({
name,
help: name,
...(labelNames ? { labelNames } : {}),
});
return (labels: LabelValues<T>) => {
const now = process.hrtime();
return () => {
const delta = process.hrtime(now);
const value = delta[0] + delta[1] / 1e9;
summary.observe(labels, value);
return value;
};
};
};
return {
counter: counterCreator,
gauge: gaugeCreator,
timer: timerCreator,
};
};
export const metricsCreator = metricsCreatorGenerator();

View File

@@ -0,0 +1,27 @@
import { NextFunction, Request, Response } from 'express';
import onHeaders from 'on-headers';
export const serverTimingAndCache = (
req: Request,
res: Response,
next: NextFunction
) => {
req.res = res;
const now = process.hrtime();
onHeaders(res, () => {
const delta = process.hrtime(now);
const costInMilliseconds = (delta[0] + delta[1] / 1e9) * 1000;
const serverTiming = res.getHeader('Server-Timing') as string | undefined;
const serverTimingValue = `${
serverTiming ? `${serverTiming}, ` : ''
}total;dur=${costInMilliseconds}`;
res.setHeader('Server-Timing', serverTimingValue);
});
res.setHeader('Cache-Control', 'max-age=0, private, must-revalidate');
next();
};

View File

@@ -1,8 +1,18 @@
import type { CanActivate, ExecutionContext } from '@nestjs/common';
import { createParamDecorator, Injectable, UseGuards } from '@nestjs/common';
import {
createParamDecorator,
Inject,
Injectable,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { NextAuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import { NextAuthOptionsProvide } from './next-auth-options';
import { AuthService } from './service';
export function getUserFromContext(context: ExecutionContext) {
@@ -42,26 +52,71 @@ export const CurrentUser = createParamDecorator(
@Injectable()
class AuthGuard implements CanActivate {
constructor(
@Inject(NextAuthOptionsProvide)
private readonly nextAuthOptions: NextAuthOptions,
private auth: AuthService,
private prisma: PrismaService
private prisma: PrismaService,
private readonly reflector: Reflector
) {}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const { req, res } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
if (!token) {
return false;
}
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
// api is public
const isPublic = this.reflector.get<boolean>(
'isPublic',
context.getHandler()
);
// api can be public, but if user is logged in, we can get user info
const isPublicable = this.reflector.get<boolean>(
'isPublicable',
context.getHandler()
);
if (isPublic) {
return true;
} else if (!token) {
const session = await AuthHandler({
req: {
cookies: req.cookies,
action: 'session',
method: 'GET',
headers: req.headers,
},
options: this.nextAuthOptions,
});
return !!req.user;
}
const { body = {}, cookies, status = 200 } = session;
if (!body && !isPublicable) {
return false;
}
// @ts-expect-error body is user here
req.user = body.user;
if (cookies && res) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
return Boolean(
status === 200 &&
typeof body !== 'string' &&
// ignore body if api is publicable
(Object.keys(body).length || isPublicable)
);
} else {
const [type, jwt] = token.split(' ') ?? [];
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
}
return false;
}
}
@@ -85,3 +140,8 @@ class AuthGuard implements CanActivate {
export const Auth = () => {
return UseGuards(AuthGuard);
};
// api is public accessible
export const Public = () => SetMetadata('isPublic', true);
// api is public accessible, but if user is logged in, we can get user info
export const Publicable = () => SetMetadata('isPublicable', true);

View File

@@ -1,13 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { MAILER, MailService } from './mailer';
import { NextAuthController } from './next-auth.controller';
import { NextAuthOptionsProvider } from './next-auth-options';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Global()
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
providers: [
AuthService,
AuthResolver,
NextAuthOptionsProvider,
MAILER,
MailService,
],
exports: [AuthService, NextAuthOptionsProvider, MailService],
controllers: [NextAuthController],
})
export class AuthModule {}

View File

@@ -0,0 +1,2 @@
export { MailService } from './mail.service';
export { MAILER } from './mailer';

View 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,
});
}
}

View 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],
};

View 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>`;
};

View 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`;
}

View File

@@ -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`);
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '@nestjs/graphql';
import type { Request } from 'express';
import { Config } from '../../config';
import { UserType } from '../users/resolver';
import { CurrentUser } from './guard';
import { AuthService } from './service';
@@ -26,7 +27,10 @@ export class TokenType {
@Resolver(() => UserType)
export class AuthResolver {
constructor(private auth: AuthService) {}
constructor(
private readonly config: Config,
private auth: AuthService
) {}
@ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
@@ -41,13 +45,13 @@ export class AuthResolver {
}
@Mutation(() => UserType)
async register(
async signUp(
@Context() ctx: { req: Request },
@Args('name') name: string,
@Args('email') email: string,
@Args('password') password: string
) {
const user = await this.auth.register(name, email, password);
const user = await this.auth.signUp(name, email, password);
ctx.req.user = user;
return user;
}
@@ -62,4 +66,56 @@ export class AuthResolver {
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async changePassword(
@Context() ctx: { req: Request },
@Args('id') id: string,
@Args('newPassword') newPassword: string
) {
const user = await this.auth.changePassword(id, newPassword);
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async changeEmail(
@Context() ctx: { req: Request },
@Args('id') id: string,
@Args('email') email: string
) {
const user = await this.auth.changeEmail(id, email);
ctx.req.user = user;
return user;
}
@Mutation(() => Boolean)
async sendChangePasswordEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendChangePasswordEmail(email, url);
return !res.rejected.length;
}
@Mutation(() => Boolean)
async sendSetPasswordEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendSetPasswordEmail(email, url);
return !res.rejected.length;
}
@Mutation(() => Boolean)
async sendChangeEmail(
@Args('email') email: string,
@Args('callbackUrl') callbackUrl: string
) {
const url = `${this.config.baseUrl}${callbackUrl}`;
const res = await this.auth.sendChangeEmail(email, url);
return !res.rejected.length;
}
}

View File

@@ -12,8 +12,14 @@ import type { User } from '@prisma/client';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
import { MailService } from './mailer';
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
export type UserClaim = Pick<
User,
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
> & {
hasPassword?: boolean;
};
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
@@ -21,7 +27,8 @@ export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
export class AuthService {
constructor(
private config: Config,
private prisma: PrismaService
private prisma: PrismaService,
private mailer: MailService
) {}
sign(user: UserClaim) {
@@ -32,6 +39,9 @@ export class AuthService {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
iat: now,
@@ -58,6 +68,9 @@ export class AuthService {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified?.toISOString(),
image: user.avatarUrl,
hasPassword: Boolean(user.hasPassword),
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
@@ -78,7 +91,7 @@ export class AuthService {
async verify(token: string) {
try {
return (
const data = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
@@ -86,6 +99,12 @@ export class AuthService {
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
return {
...data,
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
createdAt: new Date(data.createdAt),
};
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
@@ -119,7 +138,7 @@ export class AuthService {
return user;
}
async register(name: string, email: string, password: string): Promise<User> {
async signUp(name: string, email: string, password: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
@@ -140,4 +159,96 @@ export class AuthService {
},
});
}
async createAnonymousUser(email: string): Promise<User> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (user) {
throw new BadRequestException('Email already exists');
}
return this.prisma.user.create({
data: {
name: 'Unnamed',
email,
},
});
}
async getUserByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({
where: {
email,
},
});
}
async isUserHasPassword(email: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: {
email,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
return Boolean(user.password);
}
async changePassword(id: string, newPassword: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {
id,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
const hashedPassword = await hash(newPassword);
return this.prisma.user.update({
where: {
id,
},
data: {
password: hashedPassword,
},
});
}
async changeEmail(id: string, newEmail: string): Promise<User> {
const user = await this.prisma.user.findUnique({
where: {
id,
},
});
if (!user) {
throw new BadRequestException('Invalid email');
}
return this.prisma.user.update({
where: {
id,
},
data: {
email: newEmail,
},
});
}
async sendChangePasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangePasswordEmail(email, callbackUrl);
}
async sendSetPasswordEmail(email: string, callbackUrl: string) {
return this.mailer.sendSetPasswordEmail(email, callbackUrl);
}
async sendChangeEmail(email: string, callbackUrl: string) {
return this.mailer.sendChangeEmail(email, callbackUrl);
}
}

View File

@@ -0,0 +1,42 @@
import { DynamicModule } from '@nestjs/common';
import { DocManager } from './manager';
import { RedisDocManager } from './redis-manager';
export class DocModule {
/**
* @param automation whether enable update merging automation logic
*/
private static defModule(automation = true): DynamicModule {
return {
module: DocModule,
providers: [
{
provide: 'DOC_MANAGER_AUTOMATION',
useValue: automation,
},
{
provide: DocManager,
useClass: globalThis.AFFiNE.redis.enabled
? RedisDocManager
: DocManager,
},
],
exports: [DocManager],
};
}
static forRoot() {
return this.defModule();
}
static forSync(): DynamicModule {
return this.defModule(false);
}
static forFeature(): DynamicModule {
return this.defModule(false);
}
}
export { DocManager };

View File

@@ -0,0 +1,351 @@
import {
Inject,
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
if (yBinary.equals(jwstBinary)) {
return true;
}
if (strict) {
return false;
}
const doc = new Doc();
applyUpdate(doc, jwstBinary);
const yBinary2 = Buffer.from(encodeStateAsUpdate(doc));
return compare(yBinary, yBinary2, true);
}
/**
* Since we can't directly save all client updates into database, in which way the database will overload,
* we need to buffer the updates and merge them to reduce db write.
*
* And also, if a new client join, it would be nice to see the latest doc asap,
* so we need to at least store a snapshot of the doc and return quickly,
* along side all the updates that have not been applies to that snapshot(timestamp).
*
* @see [RedisUpdateManager](./redis-manager.ts) - redis backed manager
*/
@Injectable()
export class DocManager implements OnModuleInit, OnModuleDestroy {
protected logger = new Logger(DocManager.name);
private job: NodeJS.Timeout | null = null;
private busy = false;
constructor(
protected readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION')
protected readonly automation: boolean,
protected readonly config: Config,
protected readonly metrics: Metrics
) {}
onModuleInit() {
if (this.automation) {
this.logger.log('Use Database');
this.setup();
}
}
onModuleDestroy() {
this.destroy();
}
protected recoverDoc(...updates: Buffer[]): Doc {
const doc = new Doc();
updates.forEach(update => {
applyUpdate(doc, update);
});
return doc;
}
protected yjsMergeUpdates(...updates: Buffer[]): Buffer {
const doc = this.recoverDoc(...updates);
return Buffer.from(encodeStateAsUpdate(doc));
}
protected mergeUpdates(guid: string, ...updates: Buffer[]): Buffer {
const yjsResult = this.yjsMergeUpdates(...updates);
this.metrics.jwstCodecMerge(1, {});
let log = false;
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
try {
const jwstResult = jwstMergeUpdates(updates);
if (!compare(yjsResult, jwstResult)) {
this.metrics.jwstCodecDidnotMatch(1, {});
this.logger.warn(
`jwst codec result doesn't match yjs codec result for: ${guid}`
);
log = true;
if (this.config.node.dev) {
this.logger.warn(`Expected:\n ${yjsResult.toString('hex')}`);
this.logger.warn(`Result:\n ${jwstResult.toString('hex')}`);
}
}
} catch (e) {
this.metrics.jwstCodecFail(1, {});
this.logger.warn(`jwst apply update failed for :${guid}`, e);
log = true;
} finally {
if (log) {
this.logger.warn(
'Updates:',
updates.map(u => u.toString('hex'))
);
}
}
}
return yjsResult;
}
/**
* setup pending update processing loop
*/
setup() {
this.job = setInterval(() => {
if (!this.busy) {
this.busy = true;
this.apply()
.catch(() => {
/* we handle all errors in work itself */
})
.finally(() => {
this.busy = false;
});
}
}, this.config.doc.manager.updatePollInterval);
this.logger.log('Automation started');
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
this.logger.warn(
'Experimental feature enabled: merge updates with jwst codec is enabled'
);
}
}
/**
* stop pending update processing loop
*/
destroy() {
if (this.job) {
clearInterval(this.job);
this.job = null;
this.logger.log('Automation stopped');
}
}
/**
* add update to manager for later processing like fast merging.
*/
async push(workspaceId: string, guid: string, update: Buffer) {
await this.db.update.create({
data: {
workspaceId,
id: guid,
blob: update,
},
});
this.logger.verbose(
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
);
}
/**
* get the snapshot of the doc we've seen.
*/
async getSnapshot(
workspaceId: string,
guid: string
): Promise<Buffer | undefined> {
const snapshot = await this.db.snapshot.findFirst({
where: {
workspaceId,
id: guid,
},
});
return snapshot?.blob;
}
/**
* get pending updates
*/
async getUpdates(workspaceId: string, guid: string): Promise<Buffer[]> {
const updates = await this.db.update.findMany({
where: {
workspaceId,
id: guid,
},
});
return updates.map(update => update.blob);
}
/**
* get the latest doc with all update applied.
*
* latest = snapshot + updates
*/
async getLatest(workspaceId: string, guid: string): Promise<Doc | undefined> {
const snapshot = await this.getSnapshot(workspaceId, guid);
const updates = await this.getUpdates(workspaceId, guid);
if (updates.length) {
if (snapshot) {
return this.recoverDoc(snapshot, ...updates);
} else {
return this.recoverDoc(...updates);
}
}
if (snapshot) {
return this.recoverDoc(snapshot);
}
return undefined;
}
/**
* get the latest doc and convert it to update binary
*/
async getLatestUpdate(
workspaceId: string,
guid: string
): Promise<Buffer | undefined> {
const doc = await this.getLatest(workspaceId, guid);
return doc ? Buffer.from(encodeStateAsUpdate(doc)) : undefined;
}
/**
* apply pending updates to snapshot
*/
async apply() {
const updates = await this.db
.$transaction(async db => {
// find the first update and batch process updates with same id
const first = await db.update.findFirst({
orderBy: {
createdAt: 'asc',
},
});
// no pending updates
if (!first) {
return;
}
const { id, workspaceId } = first;
const updates = await db.update.findMany({
where: {
id,
workspaceId,
},
});
// no pending updates
if (!updates.length) {
return;
}
// remove update that will be merged later
await db.update.deleteMany({
where: {
id,
workspaceId,
},
});
return updates;
})
.catch(
// transaction failed, it's safe to ignore
e => {
this.logger.error('Failed to fetch updates', e);
}
);
// we put update merging logic outside transaction will make the processing more complex,
// but it's better to do so, since the merging may takes a lot of time,
// which may slow down the whole db.
if (!updates?.length) {
return;
}
const { id, workspaceId } = updates[0];
this.logger.verbose(
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
);
try {
const snapshot = await this.db.snapshot.findFirst({
where: {
workspaceId,
id,
},
});
// merge updates
const merged = snapshot
? this.mergeUpdates(id, snapshot.blob, ...updates.map(u => u.blob))
: this.mergeUpdates(id, ...updates.map(u => u.blob));
// save snapshot
await this.upsert(workspaceId, id, merged);
} catch (e) {
// failed to merge updates, put them back
this.logger.error('Failed to merge updates', e);
await this.db.update
.createMany({
data: updates.map(u => ({
id: u.id,
workspaceId: u.workspaceId,
blob: u.blob,
})),
})
.catch(e => {
// failed to recover, fallback TBD
this.logger.error('Fetal: failed to put updates back to db', e);
});
}
}
protected async upsert(workspaceId: string, guid: string, blob: Buffer) {
return this.db.snapshot.upsert({
where: {
id_workspaceId: {
id: guid,
workspaceId,
},
},
create: {
id: guid,
workspaceId,
blob,
},
update: {
blob,
},
});
}
}

View 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);
}
}
}

View File

@@ -1,5 +1,40 @@
import { DynamicModule, Type } from '@nestjs/common';
import { GqlModule } from '../graphql.module';
import { AuthModule } from './auth';
import { DocModule } from './doc';
import { SyncModule } from './sync';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
const { SERVER_FLAVOR } = process.env;
const BusinessModules: (Type | DynamicModule)[] = [];
switch (SERVER_FLAVOR) {
case 'sync':
BusinessModules.push(SyncModule, DocModule.forSync());
break;
case 'graphql':
BusinessModules.push(
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
DocModule.forRoot()
);
break;
case 'allinone':
default:
BusinessModules.push(
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
SyncModule,
DocModule.forRoot()
);
break;
}
export { BusinessModules };

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
@@ -14,10 +15,16 @@ export class FSService {
async writeFile(key: string, file: FileUpload) {
const dest = this.config.objectStorage.fs.path;
const fileName = `${key}-${randomUUID()}`;
const prefix = this.config.node.dev
? `${this.config.https ? 'https' : 'http'}://${this.config.host}:${
this.config.port
}`
: '';
await mkdir(dest, { recursive: true });
const destFile = join(dest, key);
const destFile = join(dest, fileName);
await pipeline(file.createReadStream(), createWriteStream(destFile));
return `/assets/${destFile}`;
return `${prefix}/assets/${fileName}`;
}
}

View File

@@ -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);
}

View 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';
}
}

View 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 {}

View 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;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EventsModule } from './events/events.module';
@Module({
imports: [EventsModule],
})
export class SyncModule {}

View 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;
}
}

View File

@@ -0,0 +1,11 @@
export function assertExists<T>(
val: T | null | undefined,
message: string | Error = 'val does not exist'
): asserts val is T {
if (val === null || val === undefined) {
if (message instanceof Error) {
throw message;
}
throw new Error(message);
}
}

View File

@@ -1,4 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
HttpException,
} from '@nestjs/common';
import {
Args,
Field,
@@ -6,16 +10,23 @@ import {
Mutation,
ObjectType,
Query,
registerEnumType,
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import type { FileUpload } from '../../types';
import { Auth } from '../auth/guard';
import { Auth, CurrentUser, Public } from '../auth/guard';
import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types';
registerEnumType(NewFeaturesKind, {
name: 'NewFeaturesKind',
});
@ObjectType()
export class UserType implements Partial<User> {
@@ -28,11 +39,34 @@ export class UserType implements Partial<User> {
@Field({ description: 'User email' })
email!: string;
@Field({ description: 'User avatar url', nullable: true })
avatarUrl!: string;
@Field(() => String, { description: 'User avatar url', nullable: true })
avatarUrl: string | null = null;
@Field(() => Date, { description: 'User email verified', nullable: true })
emailVerified: Date | null = null;
@Field({ description: 'User created date', nullable: true })
createdAt!: Date;
@Field(() => Boolean, {
description: 'User password has been set',
nullable: true,
})
hasPassword?: boolean;
}
@ObjectType()
export class DeleteAccount {
@Field()
success!: boolean;
}
@ObjectType()
export class AddToNewFeaturesWaitingList {
@Field()
email!: string;
@Field(() => NewFeaturesKind, { description: 'New features kind' })
type!: NewFeaturesKind;
}
@Auth()
@@ -40,17 +74,59 @@ export class UserType implements Partial<User> {
export class UserResolver {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService
private readonly storage: StorageService,
private readonly config: Config
) {}
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
})
async currentUser(@CurrentUser() user: User) {
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
hasPassword: !!user.password,
};
}
@Query(() => UserType, {
name: 'user',
description: 'Get user by email',
nullable: true,
})
@Public()
async user(@Args('email') email: string) {
return this.prisma.user.findUnique({
where: { email },
});
if (this.config.node.prod && this.config.affine.beta) {
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList
.findUnique({
where: { email, type: NewFeaturesKind.EarlyAccess },
})
.catch(() => false);
if (!hasEarlyAccess) {
return new HttpException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
401
);
}
}
// TODO: need to limit a user can only get another user witch is in the same workspace
const user = await this.prisma.user
.findUnique({
where: { email },
})
.catch(() => {
return null;
});
if (user?.password) {
const userResponse: UserType = user;
userResponse.hasPassword = true;
}
return user;
}
@Mutation(() => UserType, {
@@ -72,4 +148,45 @@ export class UserResolver {
data: { avatarUrl: url },
});
}
@Mutation(() => DeleteAccount)
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
await this.prisma.user.delete({
where: {
id: user.id,
},
});
await this.prisma.session.deleteMany({
where: {
userId: user.id,
},
});
return {
success: true,
};
}
@Mutation(() => AddToNewFeaturesWaitingList)
async addToNewFeaturesWaitingList(
@CurrentUser() user: UserType,
@Args('type', {
type: () => NewFeaturesKind,
})
type: NewFeaturesKind,
@Args('email') email: string
): Promise<AddToNewFeaturesWaitingList> {
if (!user.email.endsWith('@toeverything.info')) {
throw new ForbiddenException('You are not allowed to do this');
}
await this.prisma.newFeaturesWaitingList.create({
data: {
email,
type,
},
});
return {
email,
type,
};
}
}

View File

@@ -0,0 +1,3 @@
export enum NewFeaturesKind {
EarlyAccess,
}

View File

@@ -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)));
}
}

View File

@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { DocModule } from '../doc';
import { WorkspacesController } from './controller';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver, PermissionService, WorkspacesController],
imports: [DocModule.forFeature()],
controllers: [WorkspacesController],
providers: [WorkspaceResolver, PermissionService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { WorkspaceType } from './resolver';
export { InvitationType, WorkspaceType } from './resolver';

View File

@@ -12,6 +12,7 @@ export class PermissionService {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
@@ -20,6 +21,38 @@ export class PermissionService {
return data?.type as Permission;
}
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
if (user) {
return await this.tryCheck(ws, user);
} else {
// check if this is a public workspace
const count = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (count > 0) {
return true;
}
// check whether this is a public subpage
const workspace = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: ws,
userId: null,
},
});
const subpages = workspace
.map(ws => ws.subPageId)
.filter((v): v is string => !!v);
if (subpages.length > 0 && ws === id) {
// rootDoc is always accessible when there is a public subpage
return true;
} else {
// check if this is a public subpage
return subpages.map(page => `space:${page}`).includes(id);
}
}
}
async check(
ws: string,
user: string,
@@ -35,9 +68,21 @@ export class PermissionService {
user: string,
permission: Permission = Permission.Read
) {
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
if (data > 0) {
return true;
}
}
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
type: {
@@ -46,30 +91,18 @@ export class PermissionService {
},
});
if (data > 0) {
return true;
}
// If the permission is read, we should check if the workspace is public
if (permission === Permission.Read) {
const data = await this.prisma.workspace.count({
where: { id: ws, public: true },
});
return data > 0;
}
return false;
return data > 0;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
): Promise<string> {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: true,
},
@@ -105,22 +138,40 @@ export class PermissionService {
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p;
return p.id;
}
return this.prisma.userWorkspacePermission.create({
data: {
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: null,
userId: user,
type: permission,
},
})
.then(p => p.id);
}
async acceptById(ws: string, id: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
id,
workspaceId: ws,
userId: user,
type: permission,
},
data: {
accepted: true,
},
});
return result.count > 0;
}
async accept(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
accepted: false,
},
@@ -136,6 +187,67 @@ export class PermissionService {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: null,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
async isPageAccessible(ws: string, page: string, user?: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
return data?.accepted || false;
}
async grantPage(
ws: string,
page: string,
user?: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
},
});
if (data) {
return data.accepted;
}
return this.prisma.userWorkspacePermission
.create({
data: {
workspaceId: ws,
subPageId: page,
userId: user,
// if provide user id, user need to accept the invitation
accepted: user ? false : true,
type: permission,
},
})
.then(ret => ret.accepted);
}
async revokePage(ws: string, page: string, user?: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
subPageId: page,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading

View File

@@ -8,6 +8,7 @@ import {
Int,
Mutation,
ObjectType,
OmitType,
Parent,
PartialType,
PickType,
@@ -19,20 +20,43 @@ import {
import type { User, Workspace } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import type { FileUpload } from '../../types';
import { Auth, CurrentUser } from '../auth';
import { Auth, CurrentUser, Public } from '../auth';
import { MailService } from '../auth/mailer';
import { AuthService } from '../auth/service';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
import { Permission } from './types';
import { defaultWorkspaceAvatar } from './utils';
registerEnumType(Permission, {
name: 'Permission',
description: 'User permission in workspace',
});
@ObjectType()
export class InviteUserType extends OmitType(
PartialType(UserType),
['id'],
ObjectType
) {
@Field(() => ID)
id!: string;
@Field(() => Permission, { description: 'User permission in workspace' })
permission!: Permission;
@Field({ description: 'Invite id' })
inviteId!: string;
@Field({ description: 'User accepted' })
accepted!: boolean;
}
@ObjectType()
export class WorkspaceType implements Partial<Workspace> {
@Field(() => ID)
@@ -43,6 +67,34 @@ export class WorkspaceType implements Partial<Workspace> {
@Field({ description: 'Workspace created date' })
createdAt!: Date;
@Field(() => [InviteUserType], {
description: 'Members of workspace',
})
members!: InviteUserType[];
}
@ObjectType()
export class InvitationWorkspaceType {
@Field(() => ID)
id!: string;
@Field({ description: 'Workspace name' })
name!: string;
@Field(() => String, {
// nullable: true,
description: 'Base64 encoded avatar',
})
avatar!: string;
}
@ObjectType()
export class InvitationType {
@Field({ description: 'Workspace information' })
workspace!: InvitationWorkspaceType;
@Field({ description: 'User information' })
user!: UserType;
}
@InputType()
@@ -59,6 +111,8 @@ export class UpdateWorkspaceInput extends PickType(
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(
private readonly auth: AuthService,
private readonly mailer: MailService,
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService,
@Inject(StorageProvide) private readonly storage: Storage
@@ -69,7 +123,7 @@ export class WorkspaceResolver {
complexity: 2,
})
async permission(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
@@ -99,6 +153,20 @@ export class WorkspaceResolver {
});
}
@ResolveField(() => [String], {
description: 'Shared pages of workspace',
complexity: 2,
})
async sharedPages(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: workspace.id,
},
});
return data.map(item => item.subPageId).filter(Boolean);
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
@@ -117,27 +185,46 @@ export class WorkspaceResolver {
return data.user;
}
@ResolveField(() => [UserType], {
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: UserType,
@Parent() workspace: WorkspaceType
) {
async members(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
workspaceId: workspace.id,
accepted: true,
userId: {
not: user.id,
},
},
include: {
user: true,
},
});
return data.map(({ user }) => user);
return data.map(({ id, accepted, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
}));
}
@Query(() => Boolean, {
description: 'Get is owner of workspace',
complexity: 2,
})
async isOwner(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
return data?.user?.id === user.id;
}
@Query(() => [WorkspaceType], {
@@ -163,6 +250,22 @@ export class WorkspaceResolver {
});
}
@Query(() => WorkspaceType, {
description: 'Get public workspace by id',
})
@Public()
async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({
where: { id },
});
if (workspace?.public) {
return workspace;
}
throw new NotFoundException("Workspace doesn't exist");
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
@@ -181,7 +284,7 @@ export class WorkspaceResolver {
description: 'Create a new workspace',
})
async createWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args({ name: 'init', type: () => GraphQLUpload })
update: FileUpload
) {
@@ -215,7 +318,13 @@ export class WorkspaceResolver {
},
});
await this.storage.createWorkspace(workspace.id, buffer);
await this.prisma.snapshot.create({
data: {
id: workspace.id,
workspaceId: workspace.id,
blob: buffer,
},
});
return workspace;
}
@@ -224,11 +333,11 @@ export class WorkspaceResolver {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check('id', user.id, Permission.Admin);
await this.permissionProvider.check(id, user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
@@ -239,7 +348,7 @@ export class WorkspaceResolver {
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
@@ -248,25 +357,30 @@ export class WorkspaceResolver {
},
});
await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
await this.storage.deleteWorkspace(id);
await this.prisma.$transaction([
this.prisma.update.deleteMany({
where: {
workspaceId: id,
},
}),
this.prisma.snapshot.deleteMany({
where: {
workspaceId: id,
},
}),
]);
return true;
}
@Mutation(() => Boolean)
@Mutation(() => String)
async invite(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission
@Args('permission', { type: () => Permission }) permission: Permission,
// TODO: add rate limit
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
@@ -280,18 +394,122 @@ export class WorkspaceResolver {
},
});
if (!target) {
throw new NotFoundException("User doesn't exist");
if (target) {
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId,
userId: target.id,
},
});
if (originRecord) {
return originRecord.id;
}
const inviteId = await this.permissionProvider.grant(
workspaceId,
target.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
}
return inviteId;
} else {
const user = await this.auth.createAnonymousUser(email);
const inviteId = await this.permissionProvider.grant(
workspaceId,
user.id,
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
}
return inviteId;
}
}
@Public()
@Query(() => InvitationType, {
description: 'Update workspace',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
const permission =
await this.prisma.userWorkspacePermission.findUniqueOrThrow({
where: {
id: inviteId,
},
});
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
where: {
id: permission.workspaceId,
workspaceId: permission.workspaceId,
},
});
const doc = new Doc();
applyUpdate(doc, new Uint8Array(snapshot.blob));
const metaJSON = doc.getMap('meta').toJSON();
const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: permission.workspaceId,
type: Permission.Owner,
},
include: {
user: true,
},
});
let avatar = '';
if (metaJSON.avatar) {
const avatarBlob = await this.storage.getBlob(
permission.workspaceId,
metaJSON.avatar
);
avatar = avatarBlob?.data.toString('base64') || '';
}
await this.permissionProvider.grant(workspaceId, target.id, permission);
return true;
return {
workspace: {
name: metaJSON.name || '',
avatar: avatar || defaultWorkspaceAvatar,
id: permission.workspaceId,
},
user: owner.user,
};
}
@Mutation(() => Boolean)
async revoke(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
@@ -300,9 +518,18 @@ export class WorkspaceResolver {
return this.permissionProvider.revoke(workspaceId, userId);
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string
) {
return this.permissionProvider.acceptById(workspaceId, inviteId);
}
@Mutation(() => Boolean)
async acceptInvite(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
return this.permissionProvider.accept(workspaceId, user.id);
@@ -310,7 +537,7 @@ export class WorkspaceResolver {
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: User,
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
@@ -318,14 +545,48 @@ export class WorkspaceResolver {
return this.permissionProvider.revoke(workspaceId, user.id);
}
@Mutation(() => Boolean)
async sharePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.grantPage(workspaceId, pageId);
}
@Mutation(() => Boolean)
async revokePage(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('pageId') pageId: string
) {
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
return this.permissionProvider.revokePage(workspaceId, pageId);
}
@Query(() => [String], {
description: 'List blobs of workspace',
})
async listBlobs(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string
) {
await this.permissionProvider.check(workspaceId, user.id);
return this.storage.listBlobs(workspaceId);
}
@Mutation(() => String)
async uploadBlob(
@CurrentUser() user: User,
async setBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'blob', type: () => GraphQLUpload })
blob: FileUpload
) {
await this.permissionProvider.check(workspaceId, user.id);
await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -341,4 +602,15 @@ export class WorkspaceResolver {
return this.storage.uploadBlob(workspaceId, buffer);
}
@Mutation(() => Boolean)
async deleteBlob(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('hash') hash: string
) {
await this.permissionProvider.check(workspaceId, user.id);
return this.storage.deleteBlob(workspaceId, hash);
}
}

View 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';

View File

@@ -1,18 +1,17 @@
import type { INestApplication, OnModuleInit } from '@nestjs/common';
import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
process.on('beforeExit', () => {
app.close().catch(e => {
console.error(e);
});
});
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -5,25 +5,23 @@
type UserType {
id: ID!
"""
User name
"""
"""User name"""
name: String!
"""
User email
"""
"""User email"""
email: String!
"""
User avatar url
"""
"""User avatar url"""
avatarUrl: String
"""
User created date
"""
"""User email verified"""
emailVerified: DateTime
"""User created date"""
createdAt: DateTime
"""User password has been set"""
hasPassword: Boolean
token: TokenType!
}
@@ -32,48 +30,57 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date
"""
scalar DateTime
type DeleteAccount {
success: Boolean!
}
type AddToNewFeaturesWaitingList {
email: String!
"""New features kind"""
type: NewFeaturesKind!
}
enum NewFeaturesKind {
EarlyAccess
}
type TokenType {
token: String!
refresh: String!
}
type WorkspaceType {
type InviteUserType {
"""User name"""
name: String
"""User email"""
email: String
"""User avatar url"""
avatarUrl: String
"""User email verified"""
emailVerified: DateTime
"""User created date"""
createdAt: DateTime
"""User password has been set"""
hasPassword: Boolean
id: ID!
"""
is Public workspace
"""
public: Boolean!
"""
Workspace created date
"""
createdAt: DateTime!
"""
Permission of current signed in user in workspace
"""
"""User permission in workspace"""
permission: Permission!
"""
member count of workspace
"""
memberCount: Int!
"""Invite id"""
inviteId: String!
"""
Owner of workspace
"""
owner: UserType!
"""
Members of workspace
"""
members: [UserType!]!
"""User accepted"""
accepted: Boolean!
}
"""
User permission in workspace
"""
"""User permission in workspace"""
enum Permission {
Read
Write
@@ -81,62 +88,110 @@ enum Permission {
Owner
}
type WorkspaceType {
id: ID!
"""is Public workspace"""
public: Boolean!
"""Workspace created date"""
createdAt: DateTime!
"""Members of workspace"""
members: [InviteUserType!]!
"""Permission of current signed in user in workspace"""
permission: Permission!
"""member count of workspace"""
memberCount: Int!
"""Shared pages of workspace"""
sharedPages: [String!]!
"""Owner of workspace"""
owner: UserType!
}
type InvitationWorkspaceType {
id: ID!
"""Workspace name"""
name: String!
"""Base64 encoded avatar"""
avatar: String!
}
type InvitationType {
"""Workspace information"""
workspace: InvitationWorkspaceType!
"""User information"""
user: UserType!
}
type Query {
"""
Get all accessible workspaces for current user
"""
"""Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean!
"""Get all accessible workspaces for current user"""
workspaces: [WorkspaceType!]!
"""
Get workspace by id
"""
"""Get public workspace by id"""
publicWorkspace(id: String!): WorkspaceType!
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!
"""
Get user by email
"""
user(email: String!): UserType!
"""Update workspace"""
getInviteInfo(inviteId: String!): InvitationType!
"""List blobs of workspace"""
listBlobs(workspaceId: String!): [String!]!
"""Get current user"""
currentUser: UserType!
"""Get user by email"""
user(email: String!): UserType
}
type Mutation {
register(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType!
"""
Create a new workspace
"""
"""Create a new workspace"""
createWorkspace(init: Upload!): WorkspaceType!
"""
Update workspace
"""
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
deleteWorkspace(id: String!): Boolean!
invite(
workspaceId: String!
email: String!
permission: Permission!
): Boolean!
invite(workspaceId: String!, email: String!, permission: Permission!, sendInviteMail: Boolean): String!
revoke(workspaceId: String!, userId: String!): Boolean!
acceptInviteById(workspaceId: String!, inviteId: String!): Boolean!
acceptInvite(workspaceId: String!): Boolean!
leaveWorkspace(workspaceId: String!): Boolean!
uploadBlob(workspaceId: String!, blob: Upload!): String!
sharePage(workspaceId: String!, pageId: String!): Boolean!
revokePage(workspaceId: String!, pageId: String!): Boolean!
setBlob(workspaceId: String!, blob: Upload!): String!
deleteBlob(workspaceId: String!, hash: String!): Boolean!
"""
Upload user avatar
"""
"""Upload user avatar"""
uploadAvatar(id: String!, avatar: Upload!): UserType!
deleteAccount: DeleteAccount!
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
signUp(name: String!, email: String!, password: String!): UserType!
signIn(email: String!, password: String!): UserType!
changePassword(id: String!, newPassword: String!): UserType!
changeEmail(id: String!, email: String!): UserType!
sendChangePasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendSetPasswordEmail(email: String!, callbackUrl: String!): Boolean!
sendChangeEmail(email: String!, callbackUrl: String!): Boolean!
}
"""
The `Upload` scalar type represents a file upload.
"""
"""The `Upload` scalar type represents a file upload."""
scalar Upload
input UpdateWorkspaceInput {
"""
is Public workspace
"""
"""is Public workspace"""
public: Boolean
id: ID!
}
}

View File

@@ -1,28 +1,25 @@
import { createRequire } from 'node:module';
import type { Storage } from '@affine/storage';
import { type DynamicModule, type FactoryProvider } from '@nestjs/common';
import { Config } from '../config';
export const StorageProvide = Symbol('Storage');
const require = createRequire(import.meta.url);
let storageModule: typeof import('@affine/storage');
try {
storageModule = await import('@affine/storage');
} catch {
const require = createRequire(import.meta.url);
storageModule = require('../../storage.node');
}
export class StorageModule {
static forRoot(): DynamicModule {
const storageProvider: FactoryProvider = {
provide: StorageProvide,
useFactory: async (config: Config) => {
let StorageFactory: typeof Storage;
try {
// dev mode
StorageFactory = (await import('@affine/storage')).Storage;
} catch {
// In docker
StorageFactory = require('../../storage.node').Storage;
}
return StorageFactory.connect(config.db.url);
return storageModule.Storage.connect(config.db.url);
},
inject: [Config],
};
@@ -35,3 +32,5 @@ export class StorageModule {
};
}
}
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;

View File

@@ -30,6 +30,7 @@ describe('AppModule', () => {
password: await hash('123456'),
},
});
await client.$disconnect();
});
beforeEach(async () => {

View File

@@ -1,17 +1,19 @@
/// <reference types="../global.d.ts" />
import { ok } from 'node:assert';
import { beforeEach, test } from 'node:test';
import { equal } from 'node:assert';
import { afterEach, beforeEach, test } from 'node:test';
import { Test } from '@nestjs/testing';
import { Test, TestingModule } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import { ConfigModule } from '../config';
import { GqlModule } from '../graphql.module';
import { MetricsModule } from '../metrics';
import { AuthModule } from '../modules/auth';
import { AuthService } from '../modules/auth/service';
import { PrismaModule } from '../prisma';
let auth: AuthService;
let module: TestingModule;
// cleanup database before each test
beforeEach(async () => {
@@ -21,7 +23,7 @@ beforeEach(async () => {
});
beforeEach(async () => {
const module = await Test.createTestingModule({
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
auth: {
@@ -33,37 +35,50 @@ beforeEach(async () => {
PrismaModule,
GqlModule,
AuthModule,
MetricsModule,
],
}).compile();
auth = module.get(AuthService);
});
afterEach(async () => {
await module.close();
});
test('should be able to register and signIn', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
});
test('should be able to verify', async () => {
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
await auth.signUp('Alex Yang', 'alexyang@example.org', '123456');
await auth.signIn('alexyang@example.org', '123456');
const date = new Date();
const user = {
id: '1',
name: 'Alex Yang',
email: 'alexyang@example.org',
createdAt: new Date(),
emailVerified: date,
createdAt: date,
avatarUrl: '',
};
{
const token = await auth.sign(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
equal(claim.id, '1');
equal(claim.name, 'Alex Yang');
equal(claim.email, 'alexyang@example.org');
equal(claim.emailVerified?.toISOString(), date.toISOString());
equal(claim.createdAt.toISOString(), date.toISOString());
}
{
const token = await auth.refresh(user);
const claim = await auth.verify(token);
ok(claim.id === '1');
ok(claim.name === 'Alex Yang');
ok(claim.email === 'alexyang@example.org');
equal(claim.id, '1');
equal(claim.name, 'Alex Yang');
equal(claim.email, 'alexyang@example.org');
equal(claim.emailVerified?.toISOString(), date.toISOString());
equal(claim.createdAt.toISOString(), date.toISOString());
}
});

View File

@@ -0,0 +1,158 @@
import { deepEqual, equal, ok } from 'node:assert';
import { afterEach, beforeEach, mock, test } from 'node:test';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { register } from 'prom-client';
import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { Config, ConfigModule } from '../config';
import { MetricsModule } from '../metrics';
import { DocManager, DocModule } from '../modules/doc';
import { PrismaModule, PrismaService } from '../prisma';
import { flushDB } from './utils';
const createModule = () => {
return Test.createTestingModule({
imports: [
PrismaModule,
MetricsModule,
ConfigModule.forRoot(),
DocModule.forRoot(),
],
}).compile();
};
test('Doc Module', async t => {
let app: INestApplication;
let m: TestingModule;
let timer: Sinon.SinonFakeTimers;
// cleanup database before each test
beforeEach(async () => {
timer = Sinon.useFakeTimers({
toFake: ['setInterval'],
});
await flushDB();
m = await createModule();
app = m.createNestApplication();
app.enableShutdownHooks();
await app.init();
});
afterEach(async () => {
await app.close();
timer.restore();
});
await t.test('should setup update poll interval', async () => {
register.clear();
const m = await createModule();
const manager = m.get(DocManager);
const fake = mock.method(manager, 'setup');
await m.createNestApplication().init();
equal(fake.mock.callCount(), 1);
// @ts-expect-error private member
ok(manager.job);
});
await t.test('should be able to stop poll', async () => {
const manager = m.get(DocManager);
const fake = mock.method(manager, 'destroy');
await app.close();
equal(fake.mock.callCount(), 1);
// @ts-expect-error private member
equal(manager.job, null);
});
await t.test('should poll when intervel due', async () => {
const manager = m.get(DocManager);
const interval = m.get(Config).doc.manager.updatePollInterval;
let resolve: any;
const fake = mock.method(manager, 'apply', () => {
return new Promise(_resolve => {
resolve = _resolve;
});
});
timer.tick(interval);
equal(fake.mock.callCount(), 1);
// busy
timer.tick(interval);
// @ts-expect-error private member
equal(manager.busy, true);
equal(fake.mock.callCount(), 1);
resolve();
await timer.tickAsync(1);
// @ts-expect-error private member
equal(manager.busy, false);
timer.tick(interval);
equal(fake.mock.callCount(), 2);
});
await t.test('should merge update when intervel due', async () => {
const db = m.get(PrismaService);
const manager = m.get(DocManager);
const doc = new YDoc();
const text = doc.getText('content');
text.insert(0, 'hello');
const update = encodeStateAsUpdate(doc);
const ws = await db.workspace.create({
data: {
id: '1',
public: false,
},
});
await db.update.createMany({
data: [
{
id: '1',
workspaceId: '1',
blob: Buffer.from([0, 0]),
},
{
id: '1',
workspaceId: '1',
blob: Buffer.from(update),
},
],
});
await manager.apply();
deepEqual(await manager.getLatestUpdate(ws.id, '1'), update);
let appendUpdate = Buffer.from([]);
doc.on('update', update => {
appendUpdate = Buffer.from(update);
});
text.insert(5, 'world');
await db.update.create({
data: {
workspaceId: ws.id,
id: '1',
blob: appendUpdate,
},
});
await manager.apply();
deepEqual(
await manager.getLatestUpdate(ws.id, '1'),
encodeStateAsUpdate(doc)
);
});
});

View File

@@ -0,0 +1,86 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { AppModule } from '../app';
import { MailService } from '../modules/auth/mailer';
import { createWorkspace, getInviteInfo, inviteUser, signUp } from './utils';
describe('Mail Module', () => {
let app: INestApplication;
const client = new PrismaClient();
let mail: MailService;
// cleanup database before each test
beforeEach(async () => {
await client.$connect();
await client.user.deleteMany({});
await client.snapshot.deleteMany({});
await client.update.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
mail = module.get(MailService);
});
afterEach(async () => {
await app.close();
});
it('should send invite email', async () => {
if (mail.hasConfigured()) {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const inviteId = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const inviteInfo = await getInviteInfo(app, u1.token.token, inviteId);
const resp = await mail.sendInviteEmail(
'production@toeverything.info',
inviteId,
{
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: '',
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
}
);
ok(resp.accepted.length === 1, 'failed to send invite email');
}
});
});

View 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);
}
}
});

View File

@@ -0,0 +1,77 @@
import { ok } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import { currentUser, signUp } from './utils';
describe('User Module', () => {
let app: INestApplication;
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.$disconnect();
});
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('should register a user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
ok(typeof user.id === 'string', 'user.id is not a string');
ok(user.name === 'u1', 'user.name is not valid');
ok(user.email === 'u1@affine.pro', 'user.email is not valid');
});
it('should get current user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
const currUser = await currentUser(app, user.token.token);
ok(currUser.id === user.id, 'user.id is not valid');
ok(currUser.name === user.name, 'user.name is not valid');
ok(currUser.email === user.email, 'user.email is not valid');
ok(currUser.hasPassword, 'currUser.hasPassword is not valid');
});
it('should be able to delete user', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
await request(app.getHttpServer())
.post('/graphql')
.auth(user.token.token, { type: 'bearer' })
.send({
query: `
mutation {
deleteAccount {
success
}
}
`,
})
.expect(200);
const current = await currentUser(app, user.token.token);
ok(current == null);
});
});

View File

@@ -0,0 +1,465 @@
import type { INestApplication, LoggerService } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
// @ts-expect-error graphql-upload is not typed
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import type { TokenType } from '../modules/auth';
import type { UserType } from '../modules/users';
import type { InvitationType, WorkspaceType } from '../modules/workspaces';
export class NestDebugLogger implements LoggerService {
log(message: string): any {
console.log(message);
}
error(message: string, trace: string): any {
console.error(message, trace);
}
warn(message: string): any {
console.warn(message);
}
debug(message: string): any {
console.debug(message);
}
verbose(message: string): any {
console.log(message);
}
}
const gql = '/graphql';
async function signUp(
app: INestApplication,
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
signUp(name: "${name}", email: "${email}", password: "${password}") {
id, name, email, token { token }
}
}
`,
})
.expect(200);
return res.body.data.signUp;
}
async function currentUser(app: INestApplication, token: string) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
currentUser {
id, name, email, emailVerified, avatarUrl, createdAt, hasPassword
}
}
`,
})
.expect(200);
return res.body?.data?.currentUser;
}
async function createWorkspace(
app: INestApplication,
token: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
export async function getWorkspaceSharedPages(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
sharedPages
}
}
`,
})
.expect(200);
return res.body.data.workspace.sharedPages;
}
async function getWorkspace(
app: INestApplication,
token: string,
workspaceId: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
workspace(id: "${workspaceId}") {
id, members { id, name, email, permission, inviteId }
}
}
`,
})
.expect(200);
return res.body.data.workspace;
}
async function getPublicWorkspace(
app: INestApplication,
workspaceId: string
): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
publicWorkspace(id: "${workspaceId}") {
id
}
}
`,
})
.expect(200);
return res.body.data.publicWorkspace;
}
async function updateWorkspace(
app: INestApplication,
token: string,
workspaceId: string,
isPublic: boolean
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
updateWorkspace(input: { id: "${workspaceId}", public: ${isPublic} }) {
public
}
}
`,
})
.expect(200);
return res.body.data.updateWorkspace.public;
}
async function inviteUser(
app: INestApplication,
token: string,
workspaceId: string,
email: string,
permission: string,
sendInviteMail = false
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail})
}
`,
})
.expect(200);
return res.body.data.invite;
}
async function acceptInviteById(
app: INestApplication,
workspaceId: string,
inviteId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInviteById(workspaceId: "${workspaceId}", inviteId: "${inviteId}")
}
`,
})
.expect(200);
return res.body.data.acceptInviteById;
}
async function acceptInvite(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
acceptInvite(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.acceptInvite;
}
async function leaveWorkspace(
app: INestApplication,
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.leaveWorkspace;
}
async function revokeUser(
app: INestApplication,
token: string,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
return res.body.data.revoke;
}
async function sharePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
sharePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.sharePage;
}
async function revokePage(
app: INestApplication,
token: string,
workspaceId: string,
pageId: string
): Promise<boolean | string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revokePage(workspaceId: "${workspaceId}", pageId: "${pageId}")
}
`,
})
.expect(200);
return res.body.errors?.[0]?.message || res.body.data?.revokePage;
}
async function listBlobs(
app: INestApplication,
token: string,
workspaceId: string
): Promise<string[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
listBlobs(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.listBlobs;
}
async function setBlob(
app: INestApplication,
token: string,
workspaceId: string,
buffer: Buffer
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
name: 'setBlob',
query: `mutation setBlob($blob: Upload!) {
setBlob(workspaceId: "${workspaceId}", blob: $blob)
}`,
variables: { blob: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.blob'] }))
.attach('0', buffer, 'blob.data')
.expect(200);
return res.body.data.setBlob;
}
async function flushDB() {
const client = new PrismaClient();
await client.$connect();
const result: { tablename: string }[] =
await client.$queryRaw`SELECT tablename
FROM pg_catalog.pg_tables
WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'`;
// remove all table data
await client.$executeRawUnsafe(
`TRUNCATE TABLE ${result
.map(({ tablename }) => tablename)
.filter(name => !name.includes('migrations'))
.join(', ')}`
);
await client.$disconnect();
}
async function createTestApp() {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = module.createNestApplication();
app.use(
graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
})
);
await app.init();
return app;
}
async function getInviteInfo(
app: INestApplication,
token: string,
inviteId: string
): Promise<InvitationType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query {
getInviteInfo(inviteId: "${inviteId}") {
workspace {
id
name
avatar
}
user {
id
name
avatarUrl
}
}
}
`,
})
.expect(200);
return res.body.data.workspace;
}
export {
acceptInvite,
acceptInviteById,
createTestApp,
createWorkspace,
currentUser,
flushDB,
getInviteInfo,
getPublicWorkspace,
getWorkspace,
inviteUser,
leaveWorkspace,
listBlobs,
revokePage,
revokeUser,
setBlob,
sharePage,
signUp,
updateWorkspace,
};

View 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');
});
});

View 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
);
}
});
});

View File

@@ -1,4 +1,4 @@
import { ok } from 'node:assert';
import { deepEqual, ok, rejects } from 'node:assert';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { INestApplication } from '@nestjs/common';
@@ -9,20 +9,30 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import request from 'supertest';
import { AppModule } from '../app';
import type { TokenType } from '../modules/auth';
import type { UserType } from '../modules/users';
import type { WorkspaceType } from '../modules/workspaces';
const gql = '/graphql';
import {
acceptInvite,
createWorkspace,
getPublicWorkspace,
getWorkspaceSharedPages,
inviteUser,
revokePage,
sharePage,
signUp,
updateWorkspace,
} from './utils';
describe('Workspace Module', () => {
let app: INestApplication;
const client = new PrismaClient();
// cleanup database before each test
beforeEach(async () => {
const client = new PrismaClient();
await client.$connect();
await client.user.deleteMany({});
await client.update.deleteMany({});
await client.snapshot.deleteMany({});
await client.workspace.deleteMany({});
await client.$disconnect();
});
@@ -44,183 +54,177 @@ describe('Workspace Module', () => {
await app.close();
});
async function registerUser(
name: string,
email: string,
password: string
): Promise<UserType & { token: TokenType }> {
const res = await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
register(name: "${name}", email: "${email}", password: "${password}") {
id, name, email, token { token }
}
}
`,
})
.expect(200);
return res.body.data.register;
}
async function createWorkspace(token: string): Promise<WorkspaceType> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.field(
'operations',
JSON.stringify({
name: 'createWorkspace',
query: `mutation createWorkspace($init: Upload!) {
createWorkspace(init: $init) {
id
}
}`,
variables: { init: null },
})
)
.field('map', JSON.stringify({ '0': ['variables.init'] }))
.attach('0', Buffer.from([0, 0]), 'init.data')
.expect(200);
return res.body.data.createWorkspace;
}
async function inviteUser(
token: string,
workspaceId: string,
email: string,
permission: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission})
}
`,
})
.expect(200);
return res.body.data.invite;
}
async function acceptInvite(
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
acceptInvite(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.acceptInvite;
}
async function leaveWorkspace(
token: string,
workspaceId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
leaveWorkspace(workspaceId: "${workspaceId}")
}
`,
})
.expect(200);
return res.body.data.leaveWorkspace;
}
async function revokeUser(
token: string,
workspaceId: string,
userId: string
): Promise<boolean> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
return res.body.data.revoke;
}
it('should register a user', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '123456');
const user = await signUp(app, 'u1', 'u1@affine.pro', '123456');
ok(typeof user.id === 'string', 'user.id is not a string');
ok(user.name === 'u1', 'user.name is not valid');
ok(user.email === 'u1@affine.pro', 'user.email is not valid');
});
it('should create a workspace', async () => {
const user = await registerUser('u1', 'u1@affine.pro', '1');
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(user.token.token);
const workspace = await createWorkspace(app, user.token.token);
ok(typeof workspace.id === 'string', 'workspace.id is not a string');
});
it('should invite a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can publish workspace', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const workspace = await createWorkspace(u1.token.token);
const invite = await inviteUser(
u1.token.token,
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
u2.email,
'Admin'
true
);
ok(invite === true, 'failed to invite user');
ok(isPublic === true, 'failed to publish workspace');
const isPrivate = await updateWorkspace(
app,
user.token.token,
workspace.id,
false
);
ok(isPrivate === false, 'failed to unpublish workspace');
});
it('should accept an invite', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can read published workspace', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
rejects(
getPublicWorkspace(app, 'not_exists_ws'),
'must not get not exists workspace'
);
rejects(
getPublicWorkspace(app, workspace.id),
'must not get private workspace'
);
const accept = await acceptInvite(u2.token.token, workspace.id);
ok(accept === true, 'failed to accept invite');
await updateWorkspace(app, user.token.token, workspace.id, true);
const publicWorkspace = await getPublicWorkspace(app, workspace.id);
ok(publicWorkspace.id === workspace.id, 'failed to get public workspace');
});
it('should leave a workspace', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should share a page', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(u2.token.token, workspace.id);
const workspace = await createWorkspace(app, u1.token.token);
const leave = await leaveWorkspace(u2.token.token, workspace.id);
ok(leave === true, 'failed to leave workspace');
const share = await sharePage(app, u1.token.token, workspace.id, 'page1');
ok(share === true, 'failed to share page');
const pages = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(pages.length === 1, 'failed to get shared pages');
ok(pages[0] === 'page1', 'failed to get shared page: page1');
const msg1 = await sharePage(app, u2.token.token, workspace.id, 'page2');
ok(msg1 === 'Permission denied', 'unauthorized user can share page');
const msg2 = await revokePage(
app,
u2.token.token,
'not_exists_ws',
'page2'
);
ok(msg2 === 'Permission denied', 'unauthorized user can share page');
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await acceptInvite(app, u2.token.token, workspace.id);
const invited = await sharePage(app, u2.token.token, workspace.id, 'page2');
ok(invited === true, 'failed to share page');
const revoke = await revokePage(app, u1.token.token, workspace.id, 'page1');
ok(revoke === true, 'failed to revoke page');
const pages2 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(pages2.length === 1, 'failed to get shared pages');
ok(pages2[0] === 'page2', 'failed to get shared page: page2');
const msg3 = await revokePage(app, u1.token.token, workspace.id, 'page3');
ok(msg3 === false, 'can revoke non-exists page');
const msg4 = await revokePage(app, u1.token.token, workspace.id, 'page2');
ok(msg4 === true, 'failed to revoke page');
const page3 = await getWorkspaceSharedPages(
app,
u1.token.token,
workspace.id
);
ok(page3.length === 0, 'failed to get shared pages');
});
it('should revoke a user', async () => {
const u1 = await registerUser('u1', 'u1@affine.pro', '1');
const u2 = await registerUser('u2', 'u2@affine.pro', '1');
it('should can get workspace doc', async () => {
const u1 = await signUp(app, 'u1', 'u1@affine.pro', '1');
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '2');
const workspace = await createWorkspace(app, u1.token.token);
const workspace = await createWorkspace(u1.token.token);
await inviteUser(u1.token.token, workspace.id, u2.email, 'Admin');
const res1 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u1.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
const revoke = await revokeUser(u1.token.token, workspace.id, u2.id);
ok(revoke === true, 'failed to revoke user');
deepEqual(
res1.body,
Buffer.from([0, 0]),
'failed to get doc with u1 token'
);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(403);
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(403);
await acceptInvite(app, u2.token.token, workspace.id);
const res2 = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.auth(u2.token.token, { type: 'bearer' })
.expect(200)
.type('application/octet-stream');
deepEqual(
res2.body,
Buffer.from([0, 0]),
'failed to get doc with u2 token'
);
});
it('should be able to get public workspace doc', async () => {
const user = await signUp(app, 'u1', 'u1@affine.pro', '1');
const workspace = await createWorkspace(app, user.token.token);
const isPublic = await updateWorkspace(
app,
user.token.token,
workspace.id,
true
);
ok(isPublic === true, 'failed to publish workspace');
const res = await request(app.getHttpServer())
.get(`/api/workspaces/${workspace.id}/docs/${workspace.id}`)
.expect(200)
.type('application/octet-stream');
deepEqual(res.body, Buffer.from([0, 0]), 'failed to get public doc');
});
});

View File

@@ -6,3 +6,9 @@ export interface FileUpload {
encoding: string;
createReadStream: () => Readable;
}
export interface ReqContext {
req: Express.Request & {
res: Express.Response;
};
}

View File

@@ -0,0 +1,7 @@
export function trimGuid(ws: string, guid: string) {
if (guid.startsWith(`${ws}:space:`)) {
return guid.substring(ws.length + 1);
}
return guid;
}