mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-31 21:59:10 +08:00
feat!: affine cloud support (#3813)
Co-authored-by: Hongtao Lye <codert.sn@gmail.com> Co-authored-by: liuyi <forehalo@gmail.com> Co-authored-by: LongYinan <lynweklm@gmail.com> Co-authored-by: X1a0t <405028157@qq.com> Co-authored-by: JimmFly <yangjinfei001@gmail.com> Co-authored-by: Peng Xiao <pengxiao@outlook.com> Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com> Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: Qi <474021214@qq.com> Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator, Injectable, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
createParamDecorator,
|
||||
Inject,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { getRequestResponseFromContext } from '../../utils/nestjs';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
export function getUserFromContext(context: ExecutionContext) {
|
||||
@@ -42,26 +52,71 @@ export const CurrentUser = createParamDecorator(
|
||||
@Injectable()
|
||||
class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private auth: AuthService,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const { req } = getRequestResponseFromContext(context);
|
||||
const { req, res } = getRequestResponseFromContext(context);
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.auth.verify(jwt);
|
||||
req.user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
// api is public
|
||||
const isPublic = this.reflector.get<boolean>(
|
||||
'isPublic',
|
||||
context.getHandler()
|
||||
);
|
||||
// api can be public, but if user is logged in, we can get user info
|
||||
const isPublicable = this.reflector.get<boolean>(
|
||||
'isPublicable',
|
||||
context.getHandler()
|
||||
);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
} else if (!token) {
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
|
||||
const { body = {}, cookies, status = 200 } = session;
|
||||
if (!body && !isPublicable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @ts-expect-error body is user here
|
||||
req.user = body.user;
|
||||
if (cookies && res) {
|
||||
for (const cookie of cookies) {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
status === 200 &&
|
||||
typeof body !== 'string' &&
|
||||
// ignore body if api is publicable
|
||||
(Object.keys(body).length || isPublicable)
|
||||
);
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.auth.verify(jwt);
|
||||
req.user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
return !!req.user;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -85,3 +140,8 @@ class AuthGuard implements CanActivate {
|
||||
export const Auth = () => {
|
||||
return UseGuards(AuthGuard);
|
||||
};
|
||||
|
||||
// api is public accessible
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
// api is public accessible, but if user is logged in, we can get user info
|
||||
export const Publicable = () => SetMetadata('isPublicable', true);
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { MAILER, MailService } from './mailer';
|
||||
import { NextAuthController } from './next-auth.controller';
|
||||
import { NextAuthOptionsProvider } from './next-auth-options';
|
||||
import { AuthResolver } from './resolver';
|
||||
import { AuthService } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuthService, AuthResolver],
|
||||
exports: [AuthService],
|
||||
providers: [
|
||||
AuthService,
|
||||
AuthResolver,
|
||||
NextAuthOptionsProvider,
|
||||
MAILER,
|
||||
MailService,
|
||||
],
|
||||
exports: [AuthService, NextAuthOptionsProvider, MailService],
|
||||
controllers: [NextAuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MailService } from './mail.service';
|
||||
export { MAILER } from './mailer';
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { Config } from '../../../config';
|
||||
import {
|
||||
MAILER_SERVICE,
|
||||
type MailerService,
|
||||
type Options,
|
||||
type Response,
|
||||
} from './mailer';
|
||||
import { emailTemplate } from './template';
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
@Inject(MAILER_SERVICE) private readonly mailer: MailerService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
async sendMail(options: Options): Promise<Response> {
|
||||
return this.mailer.sendMail(options);
|
||||
}
|
||||
|
||||
hasConfigured() {
|
||||
return (
|
||||
!!this.config.auth.email.login &&
|
||||
!!this.config.auth.email.password &&
|
||||
!!this.config.auth.email.sender
|
||||
);
|
||||
}
|
||||
|
||||
async sendInviteEmail(
|
||||
to: string,
|
||||
inviteId: string,
|
||||
invitationInfo: {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
user: {
|
||||
avatar: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
) {
|
||||
console.log('invitationInfo', invitationInfo);
|
||||
|
||||
const buttonUrl = `${this.config.baseUrl}/invite/${inviteId}`;
|
||||
const workspaceAvatar = invitationInfo.workspace.avatar;
|
||||
|
||||
const content = ` <img
|
||||
src="${invitationInfo.user.avatar}"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>
|
||||
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.user.name}</span>
|
||||
<span>invited you to join</span>
|
||||
<img
|
||||
src="cid:workspaceAvatar"
|
||||
alt=""
|
||||
width="24px"
|
||||
height="24px"
|
||||
style="margin-left:10px;border-radius: 12px;object-fit: cover;vertical-align: middle"
|
||||
/>
|
||||
<span style="font-weight:500;margin-left:4px;margin-right: 10px;">${invitationInfo.workspace.name}</span>`;
|
||||
|
||||
const html = emailTemplate({
|
||||
title: 'You are invited!',
|
||||
content,
|
||||
buttonContent: 'Accept & Join',
|
||||
buttonUrl,
|
||||
});
|
||||
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Invitation to workspace`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
cid: 'workspaceAvatar',
|
||||
filename: 'image.png',
|
||||
content: workspaceAvatar,
|
||||
encoding: 'base64',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
async sendChangePasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change password</h1>
|
||||
<p>Click button to open change password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSetPasswordEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Set password</h1>
|
||||
<p>Click button to open set password page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
async sendChangeEmail(to: string, url: string) {
|
||||
const html = `
|
||||
<h1>Change Email</h1>
|
||||
<p>Click button to open change email page</p>
|
||||
<a href="${url}">${url}</a>
|
||||
`;
|
||||
return this.sendMail({
|
||||
from: this.config.auth.email.sender,
|
||||
to,
|
||||
subject: `Change password`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
import { createTransport, Transporter } from 'nodemailer';
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
|
||||
import { Config } from '../../../config';
|
||||
|
||||
export const MAILER_SERVICE = Symbol('MAILER_SERVICE');
|
||||
|
||||
export type MailerService = Transporter<SMTPTransport.SentMessageInfo>;
|
||||
export type Response = SMTPTransport.SentMessageInfo;
|
||||
export type Options = SMTPTransport.Options;
|
||||
|
||||
export const MAILER: FactoryProvider<
|
||||
Transporter<SMTPTransport.SentMessageInfo>
|
||||
> = {
|
||||
provide: MAILER_SERVICE,
|
||||
useFactory: (config: Config) => {
|
||||
return createTransport({
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: config.auth.email.login,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
});
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
export const emailTemplate = ({
|
||||
title,
|
||||
content,
|
||||
buttonContent,
|
||||
buttonUrl,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
buttonContent: string;
|
||||
buttonUrl: string;
|
||||
}) => {
|
||||
return `<body style="background: #f6f7fb; overflow: hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>${content}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${buttonUrl}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid #1e96eb;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>${buttonContent}</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a
|
||||
href="https://github.com/toeverything/AFFiNE"
|
||||
target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023 Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>`;
|
||||
};
|
||||
@@ -0,0 +1,501 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import { BadRequestException, FactoryProvider, Logger } from '@nestjs/common';
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { NextAuthOptions } from 'next-auth';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import Email, {
|
||||
type SendVerificationRequestParams,
|
||||
} from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { NewFeaturesKind } from '../users/types';
|
||||
import { MailService } from './mailer';
|
||||
import { getUtcTimestamp, UserClaim } from './service';
|
||||
|
||||
export const NextAuthOptionsProvide = Symbol('NextAuthOptions');
|
||||
|
||||
function getSchemaFromCallbackUrl(origin: string, callbackUrl: string) {
|
||||
const { searchParams } = new URL(callbackUrl, origin);
|
||||
return searchParams.has('schema') ? searchParams.get('schema') : null;
|
||||
}
|
||||
|
||||
function wrapUrlWithSchema(url: string, schema: string | null) {
|
||||
if (schema) {
|
||||
return `${schema}://open-url?${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
provide: NextAuthOptionsProvide,
|
||||
useFactory(config: Config, prisma: PrismaService, mailer: MailService) {
|
||||
const logger = new Logger('NextAuth');
|
||||
const prismaAdapter = PrismaAdapter(prisma);
|
||||
// createUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
|
||||
prismaAdapter.createUser = async data => {
|
||||
const userData = {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
avatarUrl: '',
|
||||
emailVerified: data.emailVerified,
|
||||
};
|
||||
if (data.email && !data.name) {
|
||||
userData.name = data.email.split('@')[0];
|
||||
}
|
||||
if (data.image) {
|
||||
userData.avatarUrl = data.image;
|
||||
}
|
||||
return createUser(userData);
|
||||
};
|
||||
// getUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const getUser = prismaAdapter.getUser!.bind(prismaAdapter)!;
|
||||
prismaAdapter.getUser = async id => {
|
||||
const result = await getUser(id);
|
||||
if (result) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.image = result.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
result.hasPassword = Boolean(result.password);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const nextAuthOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
// @ts-expect-error esm interop issue
|
||||
Email.default({
|
||||
server: {
|
||||
host: config.auth.email.server,
|
||||
port: config.auth.email.port,
|
||||
auth: {
|
||||
user: config.auth.email.login,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
},
|
||||
from: config.auth.email.sender,
|
||||
async sendVerificationRequest(params: SendVerificationRequestParams) {
|
||||
const { identifier, url, provider } = params;
|
||||
const { host, searchParams, origin } = new URL(url);
|
||||
const callbackUrl = searchParams.get('callbackUrl') || '';
|
||||
if (!callbackUrl) {
|
||||
throw new Error('callbackUrl is not set');
|
||||
}
|
||||
const schema = getSchemaFromCallbackUrl(origin, callbackUrl);
|
||||
const wrappedUrl = wrapUrlWithSchema(url, schema);
|
||||
// hack: check if link is opened via desktop
|
||||
const result = await mailer.sendMail({
|
||||
to: identifier,
|
||||
from: provider.from,
|
||||
subject: `Sign in to ${host}`,
|
||||
text: text({ url: wrappedUrl, host }),
|
||||
html: html({ url: wrappedUrl, host }),
|
||||
});
|
||||
logger.log(
|
||||
`send verification email success: ${result.accepted.join(', ')}`
|
||||
);
|
||||
const failed = result.rejected
|
||||
.concat(result.pending)
|
||||
.filter(Boolean);
|
||||
if (failed.length) {
|
||||
throw new Error(`Email (${failed.join(', ')}) could not be sent`);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.node.prod,
|
||||
session: {
|
||||
strategy: config.node.prod ? 'database' : 'jwt',
|
||||
},
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
logger: console,
|
||||
};
|
||||
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Credentials.default({
|
||||
name: 'Password',
|
||||
credentials: {
|
||||
email: {
|
||||
label: 'Email',
|
||||
type: 'text',
|
||||
placeholder: 'torvalds@osdl.org',
|
||||
},
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(
|
||||
credentials:
|
||||
| Record<'email' | 'password' | 'hashedPassword', string>
|
||||
| undefined
|
||||
) {
|
||||
if (!credentials) {
|
||||
return null;
|
||||
}
|
||||
const { password, hashedPassword } = credentials;
|
||||
if (!password || !hashedPassword) {
|
||||
return null;
|
||||
}
|
||||
if (!(await verify(hashedPassword, password))) {
|
||||
return null;
|
||||
}
|
||||
return credentials;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Github.default({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google) {
|
||||
nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Google.default({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
checks: 'nonce',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
picture: user.avatarUrl,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
hasPassword: Boolean(user.password),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
},
|
||||
decode: async ({ token }) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, emailVerified, id, picture, hasPassword } = (
|
||||
await jwtVerify(token, config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [config.serverId],
|
||||
leeway: config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as Omit<UserClaim, 'avatarUrl'> & {
|
||||
picture: string | undefined;
|
||||
};
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
picture,
|
||||
sub: id,
|
||||
id,
|
||||
hasPassword,
|
||||
};
|
||||
},
|
||||
};
|
||||
nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
|
||||
nextAuthOptions.callbacks = {
|
||||
session: async ({ session, user, token }) => {
|
||||
if (session.user) {
|
||||
if (user) {
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = user.id;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.image = user.image ?? user.avatarUrl;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = user.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = Boolean(user.password);
|
||||
} else {
|
||||
// technically the sub should be the same as id
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.id = token.sub;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.emailVerified = token.emailVerified;
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
session.user.hasPassword = token.hasPassword;
|
||||
}
|
||||
if (token && token.picture) {
|
||||
session.user.image = token.picture;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
signIn: async ({ profile }) => {
|
||||
if (!config.affine.beta || !config.node.prod) {
|
||||
return true;
|
||||
}
|
||||
if (profile?.email) {
|
||||
return await prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: {
|
||||
email: profile.email,
|
||||
type: NewFeaturesKind.EarlyAccess,
|
||||
},
|
||||
})
|
||||
.then(user => !!user)
|
||||
.catch(() => false);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
redirect({ url }) {
|
||||
return url;
|
||||
},
|
||||
};
|
||||
return nextAuthOptions;
|
||||
},
|
||||
inject: [Config, PrismaService, MailService],
|
||||
};
|
||||
|
||||
/**
|
||||
* Email HTML body
|
||||
* Insert invisible space into domains from being turned into a hyperlink by email
|
||||
* clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
* like they are supposed to click on it to sign in.
|
||||
*
|
||||
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
|
||||
*/
|
||||
function html(params: { url: string; host: string }) {
|
||||
const { url } = params;
|
||||
|
||||
return `
|
||||
<body style="background: #f6f7fb;overflow:hidden">
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
cellpadding="24px"
|
||||
style="
|
||||
background: #fff;
|
||||
max-width: 450px;
|
||||
margin: 32px auto 0 auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
"
|
||||
>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://affine.pro" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/affine-logo.png"
|
||||
alt="AFFiNE log"
|
||||
height="32px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
Verify your new email for AFFiNE
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
font-family: Inter;
|
||||
color: #444;
|
||||
padding-top: 0;
|
||||
"
|
||||
>
|
||||
You recently requested to change the email address associated with your
|
||||
AFFiNe account. To complete this process, please click on the
|
||||
verification link below.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="margin-left: 24px; padding-top: 0; padding-bottom: 64px">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 8px" bgcolor="#1E96EB">
|
||||
<a
|
||||
href="${url}"
|
||||
target="_blank"
|
||||
style="
|
||||
font-size: 15px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
border: 1px solid #1e96eb;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
"
|
||||
>Verify your new email address</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table
|
||||
width="100%"
|
||||
border="0"
|
||||
style="
|
||||
background: #fafafa;
|
||||
max-width: 450px;
|
||||
margin: 0 auto 32px auto;
|
||||
border-radius: 0 0 16px 16px;
|
||||
box-shadow: 0px 0px 20px 0px rgba(66, 65, 73, 0.04);
|
||||
padding: 20px;
|
||||
"
|
||||
>
|
||||
<tr align="center">
|
||||
<td>
|
||||
<table cellpadding="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://github.com/toeverything/AFFiNE" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Github.png"
|
||||
alt="AFFiNE github link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://twitter.com/AffineOfficial" target="_blank">
|
||||
<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Twitter.png"
|
||||
alt="AFFiNE twitter link"
|
||||
height="16px"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
|
||||
alt="AFFiNE discord link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.youtube.com/@affinepro" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Youtube.png"
|
||||
alt="AFFiNE youtube link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://t.me/affineworkos" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Telegram.png"
|
||||
alt="AFFiNE telegram link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="padding: 0 10px">
|
||||
<a href="https://www.reddit.com/r/Affine/" target="_blank"
|
||||
><img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/Reddit.png"
|
||||
alt="AFFiNE reddit link"
|
||||
height="16px"
|
||||
/></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
One hyper-fused platform for wildly creative minds
|
||||
</td>
|
||||
</tr>
|
||||
<tr align="center">
|
||||
<td
|
||||
style="
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: Inter;
|
||||
color: #8e8d91;
|
||||
padding-top: 8px;
|
||||
"
|
||||
>
|
||||
Copyright<img
|
||||
src="https://cdn.affine.pro/mail/2023-8-9/copyright.png"
|
||||
alt="copyright"
|
||||
height="14px"
|
||||
style="vertical-align: middle; margin: 0 4px"
|
||||
/>2023 Toeverything
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
|
||||
function text({ url, host }: { url: string; host: string }) {
|
||||
return `Sign in to ${host}\n${url}\n\n`;
|
||||
}
|
||||
@@ -1,141 +1,41 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||
import {
|
||||
All,
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Inject,
|
||||
Next,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import type { User } from '@prisma/client';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type { AuthAction, AuthOptions } from 'next-auth';
|
||||
import { pick } from 'lodash-es';
|
||||
import type { AuthAction, NextAuthOptions } from 'next-auth';
|
||||
import { AuthHandler } from 'next-auth/core';
|
||||
import Email from 'next-auth/providers/email';
|
||||
import Github from 'next-auth/providers/github';
|
||||
import Google from 'next-auth/providers/google';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { getUtcTimestamp, type UserClaim } from './service';
|
||||
import { NextAuthOptionsProvide } from './next-auth-options';
|
||||
import { AuthService } from './service';
|
||||
|
||||
const BASE_URL = '/api/auth/';
|
||||
|
||||
@Controller(BASE_URL)
|
||||
export class NextAuthController {
|
||||
private readonly nextAuthOptions: AuthOptions;
|
||||
private readonly callbackSession;
|
||||
|
||||
constructor(
|
||||
readonly config: Config,
|
||||
readonly prisma: PrismaService
|
||||
readonly prisma: PrismaService,
|
||||
private readonly authService: AuthService,
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions
|
||||
) {
|
||||
const prismaAdapter = PrismaAdapter(prisma);
|
||||
// createUser exists in the adapter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const createUser = prismaAdapter.createUser!.bind(prismaAdapter);
|
||||
prismaAdapter.createUser = async data => {
|
||||
if (data.email && !data.name) {
|
||||
data.name = data.email.split('@')[0];
|
||||
}
|
||||
return createUser(data);
|
||||
};
|
||||
this.nextAuthOptions = {
|
||||
providers: [
|
||||
// @ts-expect-error esm interop issue
|
||||
Email.default({
|
||||
server: {
|
||||
host: config.auth.email.server,
|
||||
port: config.auth.email.port,
|
||||
auth: {
|
||||
user: config.auth.email.sender,
|
||||
pass: config.auth.email.password,
|
||||
},
|
||||
},
|
||||
from: `AFFiNE <no-reply@toeverything.info>`,
|
||||
}),
|
||||
],
|
||||
// @ts-expect-error Third part library type mismatch
|
||||
adapter: prismaAdapter,
|
||||
debug: !config.prod,
|
||||
};
|
||||
|
||||
if (config.auth.oauthProviders.github) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Github.default({
|
||||
clientId: config.auth.oauthProviders.github.clientId,
|
||||
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (config.auth.oauthProviders.google) {
|
||||
this.nextAuthOptions.providers.push(
|
||||
// @ts-expect-error esm interop issue
|
||||
Google.default({
|
||||
clientId: config.auth.oauthProviders.google.clientId,
|
||||
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.nextAuthOptions.jwt = {
|
||||
encode: async ({ token, maxAge }) => {
|
||||
if (!token?.email) {
|
||||
throw new BadRequestException('Missing email in jwt token');
|
||||
}
|
||||
const user = await this.prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
const now = getUtcTimestamp();
|
||||
return sign(
|
||||
{
|
||||
data: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||
iss: this.config.serverId,
|
||||
sub: user.id,
|
||||
aud: user.name,
|
||||
jti: randomUUID({
|
||||
disableEntropyCache: true,
|
||||
}),
|
||||
},
|
||||
this.config.auth.privateKey,
|
||||
{
|
||||
algorithm: Algorithm.ES256,
|
||||
}
|
||||
);
|
||||
},
|
||||
decode: async ({ token }) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const { name, email, id } = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
leeway: this.config.auth.leeway,
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
sub: id,
|
||||
};
|
||||
},
|
||||
};
|
||||
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||
this.callbackSession = nextAuthOptions.callbacks!.session;
|
||||
}
|
||||
|
||||
@All('*')
|
||||
@@ -145,25 +45,69 @@ export class NextAuthController {
|
||||
@Query() query: Record<string, any>,
|
||||
@Next() next: NextFunction
|
||||
) {
|
||||
const nextauth = req.url // start with request url
|
||||
const [action, providerId] = req.url // start with request url
|
||||
.slice(BASE_URL.length) // make relative to baseUrl
|
||||
.replace(/\?.*/, '') // remove query part, use only path part
|
||||
.split('/') as AuthAction[]; // as array of strings;
|
||||
.split('/') as [AuthAction, string]; // as array of strings;
|
||||
if (providerId === 'credentials') {
|
||||
const { email } = req.body;
|
||||
if (email) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
req.statusCode = 401;
|
||||
req.statusMessage = 'User not found';
|
||||
req.body = null;
|
||||
throw new NotFoundException(`User not found`);
|
||||
} else {
|
||||
req.body = {
|
||||
...req.body,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatarUrl,
|
||||
hashedPassword: user.password,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const options = this.nextAuthOptions;
|
||||
if (req.method === 'POST' && action === 'session') {
|
||||
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
const user = await this.updateSession(req, req.body.data);
|
||||
// callbacks.session existed
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = ({ session }) => {
|
||||
return {
|
||||
user: {
|
||||
...pick(user, 'id', 'name', 'email'),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: !!user.password,
|
||||
},
|
||||
expires: session.expires,
|
||||
};
|
||||
};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
options.callbacks!.session = this.callbackSession;
|
||||
}
|
||||
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||
req: {
|
||||
body: req.body,
|
||||
query: query,
|
||||
method: req.method,
|
||||
action: nextauth[0],
|
||||
providerId: nextauth[1],
|
||||
error: query.error ?? nextauth[1],
|
||||
action,
|
||||
providerId,
|
||||
error: query.error ?? providerId,
|
||||
cookies: req.cookies,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
options,
|
||||
});
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
for (const { key, value } of headers) {
|
||||
res.setHeader(key, value);
|
||||
@@ -174,8 +118,32 @@ export class NextAuthController {
|
||||
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||
}
|
||||
}
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
res.redirect('https://community.affine.pro/c/insider-general/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
res.status(status);
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
res.redirect(redirect);
|
||||
console.log(providerId, action, req.headers);
|
||||
if (providerId === 'credentials') {
|
||||
res.send(JSON.stringify({ ok: true, url: redirect }));
|
||||
} else if (
|
||||
action === 'callback' ||
|
||||
action === 'error' ||
|
||||
(providerId !== 'credentials' &&
|
||||
// login in the next-auth page, /api/auth/signin, auto redirect.
|
||||
// otherwise, return the json value to allow frontend to handle the redirect.
|
||||
req.headers?.referer?.includes?.('/api/auth/signin'))
|
||||
) {
|
||||
res.redirect(redirect);
|
||||
} else {
|
||||
res.json({ url: redirect });
|
||||
}
|
||||
} else if (typeof body === 'string') {
|
||||
res.send(body);
|
||||
} else if (body && typeof body === 'object') {
|
||||
@@ -184,4 +152,91 @@ export class NextAuthController {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSession(
|
||||
req: Request,
|
||||
newSession: Partial<Omit<User, 'id'>> & { oldPassword?: string }
|
||||
): Promise<User> {
|
||||
const { name, email, password, oldPassword } = newSession;
|
||||
if (!name && !email && !password) {
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
if (password) {
|
||||
const user = await this.getUserFromRequest(req);
|
||||
const { password: userPassword } = user;
|
||||
if (!oldPassword) {
|
||||
if (userPassword) {
|
||||
throw new BadRequestException(
|
||||
`Old password is required to update password`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!userPassword) {
|
||||
throw new BadRequestException(`No existed password`);
|
||||
}
|
||||
if (await verify(userPassword, oldPassword)) {
|
||||
await this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
...pick(newSession, 'email', 'name'),
|
||||
password: await hash(password),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return user;
|
||||
} else {
|
||||
const user = await this.getUserFromRequest(req);
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: pick(newSession, 'name', 'email'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserFromRequest(req: Request): Promise<User> {
|
||||
const token = req.headers.authorization;
|
||||
if (!token) {
|
||||
const session = await AuthHandler({
|
||||
req: {
|
||||
cookies: req.cookies,
|
||||
action: 'session',
|
||||
method: 'GET',
|
||||
headers: req.headers,
|
||||
},
|
||||
options: this.nextAuthOptions,
|
||||
});
|
||||
|
||||
const { body } = session;
|
||||
// @ts-expect-error check if body.user exists
|
||||
if (body && body.user && body.user.id) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
// @ts-expect-error body.user.id exists
|
||||
id: body.user.id,
|
||||
},
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const [type, jwt] = token.split(' ') ?? [];
|
||||
|
||||
if (type === 'Bearer') {
|
||||
const claims = await this.authService.verify(jwt);
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: claims.id },
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new BadRequestException(`User not found`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { CurrentUser } from './guard';
|
||||
import { AuthService } from './service';
|
||||
@@ -26,7 +27,10 @@ export class TokenType {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class AuthResolver {
|
||||
constructor(private auth: AuthService) {}
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private auth: AuthService
|
||||
) {}
|
||||
|
||||
@ResolveField(() => TokenType)
|
||||
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
|
||||
@@ -41,13 +45,13 @@ export class AuthResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async register(
|
||||
async signUp(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('name') name: string,
|
||||
@Args('email') email: string,
|
||||
@Args('password') password: string
|
||||
) {
|
||||
const user = await this.auth.register(name, email, password);
|
||||
const user = await this.auth.signUp(name, email, password);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
@@ -62,4 +66,56 @@ export class AuthResolver {
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async changePassword(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
const user = await this.auth.changePassword(id, newPassword);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType)
|
||||
async changeEmail(
|
||||
@Context() ctx: { req: Request },
|
||||
@Args('id') id: string,
|
||||
@Args('email') email: string
|
||||
) {
|
||||
const user = await this.auth.changeEmail(id, email);
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendChangePasswordEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangePasswordEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendSetPasswordEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendSetPasswordEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sendChangeEmail(
|
||||
@Args('email') email: string,
|
||||
@Args('callbackUrl') callbackUrl: string
|
||||
) {
|
||||
const url = `${this.config.baseUrl}${callbackUrl}`;
|
||||
const res = await this.auth.sendChangeEmail(email, url);
|
||||
return !res.rejected.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,14 @@ import type { User } from '@prisma/client';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { MailService } from './mailer';
|
||||
|
||||
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
|
||||
export type UserClaim = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'createdAt' | 'avatarUrl'
|
||||
> & {
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
|
||||
@@ -21,7 +27,8 @@ export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private config: Config,
|
||||
private prisma: PrismaService
|
||||
private prisma: PrismaService,
|
||||
private mailer: MailService
|
||||
) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
@@ -32,6 +39,9 @@ export class AuthService {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
iat: now,
|
||||
@@ -58,6 +68,9 @@ export class AuthService {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified?.toISOString(),
|
||||
image: user.avatarUrl,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
},
|
||||
exp: now + this.config.auth.refreshTokenExpiresIn,
|
||||
@@ -78,7 +91,7 @@ export class AuthService {
|
||||
|
||||
async verify(token: string) {
|
||||
try {
|
||||
return (
|
||||
const data = (
|
||||
await jwtVerify(token, this.config.auth.publicKey, {
|
||||
algorithms: [Algorithm.ES256],
|
||||
iss: [this.config.serverId],
|
||||
@@ -86,6 +99,12 @@ export class AuthService {
|
||||
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||
})
|
||||
).data as UserClaim;
|
||||
|
||||
return {
|
||||
...data,
|
||||
emailVerified: data.emailVerified ? new Date(data.emailVerified) : null,
|
||||
createdAt: new Date(data.createdAt),
|
||||
};
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
@@ -119,7 +138,7 @@ export class AuthService {
|
||||
return user;
|
||||
}
|
||||
|
||||
async register(name: string, email: string, password: string): Promise<User> {
|
||||
async signUp(name: string, email: string, password: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@@ -140,4 +159,96 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createAnonymousUser(email: string): Promise<User> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new BadRequestException('Email already exists');
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name: 'Unnamed',
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async isUserHasPassword(email: string): Promise<boolean> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
return Boolean(user.password);
|
||||
}
|
||||
|
||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
const hashedPassword = await hash(newPassword);
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
async changeEmail(id: string, newEmail: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('Invalid email');
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
email: newEmail,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangePasswordEmail(email, callbackUrl);
|
||||
}
|
||||
async sendSetPasswordEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendSetPasswordEmail(email, callbackUrl);
|
||||
}
|
||||
async sendChangeEmail(email: string, callbackUrl: string) {
|
||||
return this.mailer.sendChangeEmail(email, callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { Metrics } from '../../metrics/metrics';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
||||
|
||||
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
if (yBinary.equals(jwstBinary)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doc = new Doc();
|
||||
applyUpdate(doc, jwstBinary);
|
||||
|
||||
const yBinary2 = Buffer.from(encodeStateAsUpdate(doc));
|
||||
|
||||
return compare(yBinary, yBinary2, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we can't directly save all client updates into database, in which way the database will overload,
|
||||
* we need to buffer the updates and merge them to reduce db write.
|
||||
*
|
||||
* And also, if a new client join, it would be nice to see the latest doc asap,
|
||||
* so we need to at least store a snapshot of the doc and return quickly,
|
||||
* along side all the updates that have not been applies to that snapshot(timestamp).
|
||||
*
|
||||
* @see [RedisUpdateManager](./redis-manager.ts) - redis backed manager
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
protected logger = new Logger(DocManager.name);
|
||||
private job: NodeJS.Timeout | null = null;
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
protected readonly db: PrismaService,
|
||||
@Inject('DOC_MANAGER_AUTOMATION')
|
||||
protected readonly automation: boolean,
|
||||
protected readonly config: Config,
|
||||
protected readonly metrics: Metrics
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (this.automation) {
|
||||
this.logger.log('Use Database');
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
protected recoverDoc(...updates: Buffer[]): Doc {
|
||||
const doc = new Doc();
|
||||
|
||||
updates.forEach(update => {
|
||||
applyUpdate(doc, update);
|
||||
});
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
protected yjsMergeUpdates(...updates: Buffer[]): Buffer {
|
||||
const doc = this.recoverDoc(...updates);
|
||||
|
||||
return Buffer.from(encodeStateAsUpdate(doc));
|
||||
}
|
||||
|
||||
protected mergeUpdates(guid: string, ...updates: Buffer[]): Buffer {
|
||||
const yjsResult = this.yjsMergeUpdates(...updates);
|
||||
this.metrics.jwstCodecMerge(1, {});
|
||||
let log = false;
|
||||
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
|
||||
try {
|
||||
const jwstResult = jwstMergeUpdates(updates);
|
||||
if (!compare(yjsResult, jwstResult)) {
|
||||
this.metrics.jwstCodecDidnotMatch(1, {});
|
||||
this.logger.warn(
|
||||
`jwst codec result doesn't match yjs codec result for: ${guid}`
|
||||
);
|
||||
log = true;
|
||||
if (this.config.node.dev) {
|
||||
this.logger.warn(`Expected:\n ${yjsResult.toString('hex')}`);
|
||||
this.logger.warn(`Result:\n ${jwstResult.toString('hex')}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.metrics.jwstCodecFail(1, {});
|
||||
this.logger.warn(`jwst apply update failed for :${guid}`, e);
|
||||
log = true;
|
||||
} finally {
|
||||
if (log) {
|
||||
this.logger.warn(
|
||||
'Updates:',
|
||||
updates.map(u => u.toString('hex'))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return yjsResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* setup pending update processing loop
|
||||
*/
|
||||
setup() {
|
||||
this.job = setInterval(() => {
|
||||
if (!this.busy) {
|
||||
this.busy = true;
|
||||
this.apply()
|
||||
.catch(() => {
|
||||
/* we handle all errors in work itself */
|
||||
})
|
||||
.finally(() => {
|
||||
this.busy = false;
|
||||
});
|
||||
}
|
||||
}, this.config.doc.manager.updatePollInterval);
|
||||
|
||||
this.logger.log('Automation started');
|
||||
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
|
||||
this.logger.warn(
|
||||
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* stop pending update processing loop
|
||||
*/
|
||||
destroy() {
|
||||
if (this.job) {
|
||||
clearInterval(this.job);
|
||||
this.job = null;
|
||||
this.logger.log('Automation stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* add update to manager for later processing like fast merging.
|
||||
*/
|
||||
async push(workspaceId: string, guid: string, update: Buffer) {
|
||||
await this.db.update.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
blob: update,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.verbose(
|
||||
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* get the snapshot of the doc we've seen.
|
||||
*/
|
||||
async getSnapshot(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<Buffer | undefined> {
|
||||
const snapshot = await this.db.snapshot.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
},
|
||||
});
|
||||
|
||||
return snapshot?.blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* get pending updates
|
||||
*/
|
||||
async getUpdates(workspaceId: string, guid: string): Promise<Buffer[]> {
|
||||
const updates = await this.db.update.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
},
|
||||
});
|
||||
|
||||
return updates.map(update => update.blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* get the latest doc with all update applied.
|
||||
*
|
||||
* latest = snapshot + updates
|
||||
*/
|
||||
async getLatest(workspaceId: string, guid: string): Promise<Doc | undefined> {
|
||||
const snapshot = await this.getSnapshot(workspaceId, guid);
|
||||
const updates = await this.getUpdates(workspaceId, guid);
|
||||
|
||||
if (updates.length) {
|
||||
if (snapshot) {
|
||||
return this.recoverDoc(snapshot, ...updates);
|
||||
} else {
|
||||
return this.recoverDoc(...updates);
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshot) {
|
||||
return this.recoverDoc(snapshot);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* get the latest doc and convert it to update binary
|
||||
*/
|
||||
async getLatestUpdate(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<Buffer | undefined> {
|
||||
const doc = await this.getLatest(workspaceId, guid);
|
||||
|
||||
return doc ? Buffer.from(encodeStateAsUpdate(doc)) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* apply pending updates to snapshot
|
||||
*/
|
||||
async apply() {
|
||||
const updates = await this.db
|
||||
.$transaction(async db => {
|
||||
// find the first update and batch process updates with same id
|
||||
const first = await db.update.findFirst({
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
// no pending updates
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, workspaceId } = first;
|
||||
const updates = await db.update.findMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
// no pending updates
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove update that will be merged later
|
||||
await db.update.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return updates;
|
||||
})
|
||||
.catch(
|
||||
// transaction failed, it's safe to ignore
|
||||
e => {
|
||||
this.logger.error('Failed to fetch updates', e);
|
||||
}
|
||||
);
|
||||
|
||||
// we put update merging logic outside transaction will make the processing more complex,
|
||||
// but it's better to do so, since the merging may takes a lot of time,
|
||||
// which may slow down the whole db.
|
||||
if (!updates?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, workspaceId } = updates[0];
|
||||
|
||||
this.logger.verbose(
|
||||
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
|
||||
);
|
||||
|
||||
try {
|
||||
const snapshot = await this.db.snapshot.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
// merge updates
|
||||
const merged = snapshot
|
||||
? this.mergeUpdates(id, snapshot.blob, ...updates.map(u => u.blob))
|
||||
: this.mergeUpdates(id, ...updates.map(u => u.blob));
|
||||
|
||||
// save snapshot
|
||||
await this.upsert(workspaceId, id, merged);
|
||||
} catch (e) {
|
||||
// failed to merge updates, put them back
|
||||
this.logger.error('Failed to merge updates', e);
|
||||
|
||||
await this.db.update
|
||||
.createMany({
|
||||
data: updates.map(u => ({
|
||||
id: u.id,
|
||||
workspaceId: u.workspaceId,
|
||||
blob: u.blob,
|
||||
})),
|
||||
})
|
||||
.catch(e => {
|
||||
// failed to recover, fallback TBD
|
||||
this.logger.error('Fetal: failed to put updates back to db', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected async upsert(workspaceId: string, guid: string, blob: Buffer) {
|
||||
return this.db.snapshot.upsert({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: guid,
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: guid,
|
||||
workspaceId,
|
||||
blob,
|
||||
},
|
||||
update: {
|
||||
blob,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { Metrics } from '../../metrics/metrics';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { DocManager } from './manager';
|
||||
|
||||
function makeKey(prefix: string) {
|
||||
return (parts: TemplateStringsArray, ...args: any[]) => {
|
||||
return parts.reduce((prev, curr, i) => {
|
||||
return prev + curr + (args[i] || '');
|
||||
}, prefix);
|
||||
};
|
||||
}
|
||||
|
||||
const pending = 'um_pending:';
|
||||
const updates = makeKey('um_u:');
|
||||
const lock = makeKey('um_l:');
|
||||
|
||||
const pushUpdateLua = `
|
||||
redis.call('sadd', KEYS[1], ARGV[1])
|
||||
redis.call('rpush', KEYS[2], ARGV[2])
|
||||
`;
|
||||
|
||||
@Injectable()
|
||||
export class RedisDocManager extends DocManager {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
protected override readonly db: PrismaService,
|
||||
@Inject('DOC_MANAGER_AUTOMATION')
|
||||
protected override readonly automation: boolean,
|
||||
protected override readonly config: Config,
|
||||
protected override readonly metrics: Metrics
|
||||
) {
|
||||
super(db, automation, config, metrics);
|
||||
this.redis = new Redis(config.redis);
|
||||
this.redis.defineCommand('pushDocUpdate', {
|
||||
numberOfKeys: 2,
|
||||
lua: pushUpdateLua,
|
||||
});
|
||||
}
|
||||
|
||||
override onModuleInit(): void {
|
||||
if (this.automation) {
|
||||
this.logger.log('Use Redis');
|
||||
this.setup();
|
||||
}
|
||||
}
|
||||
|
||||
override async push(workspaceId: string, guid: string, update: Buffer) {
|
||||
try {
|
||||
const key = `${workspaceId}:${guid}`;
|
||||
|
||||
// @ts-expect-error custom command
|
||||
this.redis.pushDocUpdate(pending, updates`${key}`, key, update);
|
||||
|
||||
this.logger.verbose(
|
||||
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
|
||||
);
|
||||
} catch (e) {
|
||||
return await super.push(workspaceId, guid, update);
|
||||
}
|
||||
}
|
||||
|
||||
override async getUpdates(
|
||||
workspaceId: string,
|
||||
guid: string
|
||||
): Promise<Buffer[]> {
|
||||
try {
|
||||
return this.redis.lrangeBuffer(updates`${workspaceId}:${guid}`, 0, -1);
|
||||
} catch (e) {
|
||||
return super.getUpdates(workspaceId, guid);
|
||||
}
|
||||
}
|
||||
|
||||
override async apply(): Promise<void> {
|
||||
// incase some update fallback to db
|
||||
await super.apply();
|
||||
|
||||
const pendingDoc = await this.redis.spop(pending).catch(() => null); // safe
|
||||
|
||||
if (!pendingDoc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateKey = updates`${pendingDoc}`;
|
||||
const lockKey = lock`${pendingDoc}`;
|
||||
const splitAt = pendingDoc.indexOf(':');
|
||||
const workspaceId = pendingDoc.substring(0, splitAt);
|
||||
const id = pendingDoc.substring(splitAt + 1);
|
||||
|
||||
// acquire the lock
|
||||
const lockResult = await this.redis
|
||||
.set(
|
||||
lockKey,
|
||||
'1',
|
||||
'EX',
|
||||
// 10mins, incase progress exit in between lock require & release, which is a rare.
|
||||
// if the lock is really hold more then 10mins, we should check the merge logic correctness
|
||||
600,
|
||||
'NX'
|
||||
)
|
||||
.catch(() => null); // safe;
|
||||
|
||||
if (!lockResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// fetch pending updates
|
||||
const updates = await this.redis
|
||||
.lrangeBuffer(updateKey, 0, -1)
|
||||
.catch(() => []); // safe
|
||||
|
||||
if (!updates.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.verbose(
|
||||
`applying ${updates.length} updates for workspace: ${workspaceId}, guid: ${id}`
|
||||
);
|
||||
|
||||
const snapshot = await this.getSnapshot(workspaceId, id);
|
||||
|
||||
// merge
|
||||
const blob = snapshot
|
||||
? this.mergeUpdates(id, snapshot, ...updates)
|
||||
: this.mergeUpdates(id, ...updates);
|
||||
|
||||
// update snapshot
|
||||
|
||||
await this.upsert(workspaceId, id, blob);
|
||||
|
||||
// delete merged updates
|
||||
await this.redis
|
||||
.ltrim(updateKey, updates.length, -1)
|
||||
// safe, fallback to mergeUpdates
|
||||
.catch(e => {
|
||||
this.logger.error('Failed to remove merged updates from Redis', e);
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to merge updates with snapshot', e);
|
||||
await this.redis.sadd(pending, `${workspaceId}:${id}`).catch(() => null); // safe
|
||||
} finally {
|
||||
await this.redis.del(lockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,40 @@
|
||||
import { DynamicModule, Type } from '@nestjs/common';
|
||||
|
||||
import { GqlModule } from '../graphql.module';
|
||||
import { AuthModule } from './auth';
|
||||
import { DocModule } from './doc';
|
||||
import { SyncModule } from './sync';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
|
||||
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
|
||||
const { SERVER_FLAVOR } = process.env;
|
||||
|
||||
const BusinessModules: (Type | DynamicModule)[] = [];
|
||||
|
||||
switch (SERVER_FLAVOR) {
|
||||
case 'sync':
|
||||
BusinessModules.push(SyncModule, DocModule.forSync());
|
||||
break;
|
||||
case 'graphql':
|
||||
BusinessModules.push(
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
DocModule.forRoot()
|
||||
);
|
||||
break;
|
||||
case 'allinone':
|
||||
default:
|
||||
BusinessModules.push(
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
SyncModule,
|
||||
DocModule.forRoot()
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
export { BusinessModules };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
@@ -14,10 +15,16 @@ export class FSService {
|
||||
|
||||
async writeFile(key: string, file: FileUpload) {
|
||||
const dest = this.config.objectStorage.fs.path;
|
||||
const fileName = `${key}-${randomUUID()}`;
|
||||
const prefix = this.config.node.dev
|
||||
? `${this.config.https ? 'https' : 'http'}://${this.config.host}:${
|
||||
this.config.port
|
||||
}`
|
||||
: '';
|
||||
await mkdir(dest, { recursive: true });
|
||||
const destFile = join(dest, key);
|
||||
const destFile = join(dest, fileName);
|
||||
await pipeline(file.createReadStream(), createWriteStream(destFile));
|
||||
|
||||
return `/assets/${destFile}`;
|
||||
return `${prefix}/assets/${fileName}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import { fileTypeFromBuffer } from 'file-type';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { FileUpload } from '../../types';
|
||||
@@ -16,14 +19,21 @@ export class StorageService {
|
||||
|
||||
async uploadFile(key: string, file: FileUpload) {
|
||||
if (this.config.objectStorage.r2.enabled) {
|
||||
const readableFile = file.createReadStream();
|
||||
const fileBuffer = await getStreamAsBuffer(readableFile);
|
||||
const mime = (await fileTypeFromBuffer(fileBuffer))?.mime;
|
||||
const crc32Value = crc32(fileBuffer);
|
||||
const keyWithCrc32 = `${crc32Value}-${key}`;
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Body: file.createReadStream(),
|
||||
Body: fileBuffer,
|
||||
Bucket: this.config.objectStorage.r2.bucket,
|
||||
Key: key,
|
||||
Key: keyWithCrc32,
|
||||
ContentLength: fileBuffer.length,
|
||||
ContentType: mime,
|
||||
})
|
||||
);
|
||||
return `https://avatar.affineassets.com/${key}`;
|
||||
return `https://avatar.affineassets.com/${keyWithCrc32}`;
|
||||
} else {
|
||||
return this.fs.writeFile(key, file);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
import { Metrics } from '../../../metrics/metrics';
|
||||
import { trimGuid } from '../../../utils/doc';
|
||||
import { DocManager } from '../../doc';
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: process.env.NODE_ENV !== 'production',
|
||||
})
|
||||
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private connectionCount = 0;
|
||||
|
||||
constructor(
|
||||
private readonly docManager: DocManager,
|
||||
private readonly metric: Metrics
|
||||
) {}
|
||||
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
handleConnection() {
|
||||
this.connectionCount++;
|
||||
this.metric.socketIOConnectionGauge(this.connectionCount, {});
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
this.connectionCount--;
|
||||
this.metric.socketIOConnectionGauge(this.connectionCount, {});
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-handshake')
|
||||
async handleClientHandShake(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-handshake' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'client-handshake',
|
||||
});
|
||||
await client.join(workspaceId);
|
||||
endTimer();
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave')
|
||||
async handleClientLeave(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-leave' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'client-leave',
|
||||
});
|
||||
await client.leave(workspaceId);
|
||||
endTimer();
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-update')
|
||||
async handleClientUpdate(
|
||||
@MessageBody()
|
||||
message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
update: string;
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-update' });
|
||||
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
|
||||
const update = Buffer.from(message.update, 'base64');
|
||||
client.to(message.workspaceId).emit('server-update', message);
|
||||
const guid = trimGuid(message.workspaceId, message.guid);
|
||||
|
||||
await this.docManager.push(message.workspaceId, guid, update);
|
||||
endTimer();
|
||||
}
|
||||
|
||||
@SubscribeMessage('doc-load')
|
||||
async loadDoc(
|
||||
@MessageBody()
|
||||
message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
stateVector?: string;
|
||||
targetClientId?: number;
|
||||
}
|
||||
): Promise<{ missing: string; state?: string } | false> {
|
||||
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
|
||||
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
|
||||
const guid = trimGuid(message.workspaceId, message.guid);
|
||||
const doc = await this.docManager.getLatest(message.workspaceId, guid);
|
||||
|
||||
if (!doc) {
|
||||
endTimer();
|
||||
return false;
|
||||
}
|
||||
|
||||
const missing = Buffer.from(
|
||||
encodeStateAsUpdate(
|
||||
doc,
|
||||
message.stateVector
|
||||
? Buffer.from(message.stateVector, 'base64')
|
||||
: undefined
|
||||
)
|
||||
).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||
|
||||
endTimer();
|
||||
return {
|
||||
missing,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-init')
|
||||
async handleInitAwareness(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'awareness-init' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'init-awareness',
|
||||
});
|
||||
client.to(workspaceId).emit('new-client-awareness-init');
|
||||
endTimer();
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-update')
|
||||
async handleHelpGatheringAwareness(
|
||||
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'awareness-update' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'awareness-update',
|
||||
});
|
||||
client.to(message.workspaceId).emit('server-awareness-broadcast', {
|
||||
...message,
|
||||
});
|
||||
|
||||
endTimer();
|
||||
return 'ack';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DocModule } from '../../doc';
|
||||
import { EventsGateway } from './events.gateway';
|
||||
import { WorkspaceService } from './workspace';
|
||||
|
||||
@Module({
|
||||
imports: [DocModule.forFeature()],
|
||||
providers: [EventsGateway, WorkspaceService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { DocManager } from '../../doc';
|
||||
import { assertExists } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
constructor(private readonly docManager: DocManager) {}
|
||||
|
||||
async getDocsFromWorkspaceId(workspaceId: string): Promise<
|
||||
Array<{
|
||||
guid: string;
|
||||
update: Buffer;
|
||||
}>
|
||||
> {
|
||||
const docs: Array<{
|
||||
guid: string;
|
||||
update: Buffer;
|
||||
}> = [];
|
||||
const queue: Array<[string, Doc]> = [];
|
||||
// Workspace Doc's guid is the same as workspaceId. This is achieved by when creating a new workspace, the doc guid
|
||||
// is manually set to workspaceId.
|
||||
const doc = await this.docManager.getLatest(workspaceId, workspaceId);
|
||||
if (doc) {
|
||||
queue.push([workspaceId, doc]);
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const head = queue.pop();
|
||||
assertExists(head);
|
||||
const [guid, doc] = head;
|
||||
docs.push({
|
||||
guid: guid,
|
||||
update: Buffer.from(encodeStateAsUpdate(doc)),
|
||||
});
|
||||
|
||||
for (const { guid } of doc.subdocs) {
|
||||
const subDoc = await this.docManager.getLatest(workspaceId, guid);
|
||||
if (subDoc) {
|
||||
queue.push([guid, subDoc]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { EventsModule } from './events/events.module';
|
||||
|
||||
@Module({
|
||||
imports: [EventsModule],
|
||||
})
|
||||
export class SyncModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||
import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Redis } from 'ioredis';
|
||||
import { ServerOptions } from 'socket.io';
|
||||
|
||||
export class RedisIoAdapter extends IoAdapter {
|
||||
private adapterConstructor: ReturnType<typeof createAdapter> | undefined;
|
||||
|
||||
async connectToRedis(
|
||||
host: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
db: number
|
||||
): Promise<void> {
|
||||
const pubClient = new Redis(port, host, {
|
||||
username,
|
||||
password,
|
||||
db,
|
||||
});
|
||||
pubClient.on('error', err => {
|
||||
console.error(err);
|
||||
});
|
||||
const subClient = pubClient.duplicate();
|
||||
subClient.on('error', err => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
this.adapterConstructor = createAdapter(pubClient, subClient);
|
||||
}
|
||||
|
||||
override createIOServer(port: number, options?: ServerOptions): any {
|
||||
const server = super.createIOServer(port, options);
|
||||
server.adapter(this.adapterConstructor);
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function assertExists<T>(
|
||||
val: T | null | undefined,
|
||||
message: string | Error = 'val does not exist'
|
||||
): asserts val is T {
|
||||
if (val === null || val === undefined) {
|
||||
if (message instanceof Error) {
|
||||
throw message;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
@@ -6,16 +10,23 @@ import {
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
registerEnumType,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User } from '@prisma/client';
|
||||
// @ts-expect-error graphql-upload is not typed
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth } from '../auth/guard';
|
||||
import { Auth, CurrentUser, Public } from '../auth/guard';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { NewFeaturesKind } from './types';
|
||||
|
||||
registerEnumType(NewFeaturesKind, {
|
||||
name: 'NewFeaturesKind',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class UserType implements Partial<User> {
|
||||
@@ -28,11 +39,34 @@ export class UserType implements Partial<User> {
|
||||
@Field({ description: 'User email' })
|
||||
email!: string;
|
||||
|
||||
@Field({ description: 'User avatar url', nullable: true })
|
||||
avatarUrl!: string;
|
||||
@Field(() => String, { description: 'User avatar url', nullable: true })
|
||||
avatarUrl: string | null = null;
|
||||
|
||||
@Field(() => Date, { description: 'User email verified', nullable: true })
|
||||
emailVerified: Date | null = null;
|
||||
|
||||
@Field({ description: 'User created date', nullable: true })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
description: 'User password has been set',
|
||||
nullable: true,
|
||||
})
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class DeleteAccount {
|
||||
@Field()
|
||||
success!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AddToNewFeaturesWaitingList {
|
||||
@Field()
|
||||
email!: string;
|
||||
@Field(() => NewFeaturesKind, { description: 'New features kind' })
|
||||
type!: NewFeaturesKind;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@@ -40,17 +74,59 @@ export class UserType implements Partial<User> {
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService
|
||||
private readonly storage: StorageService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
})
|
||||
async currentUser(@CurrentUser() user: User) {
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdAt: user.createdAt,
|
||||
hasPassword: !!user.password,
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => UserType, {
|
||||
name: 'user',
|
||||
description: 'Get user by email',
|
||||
nullable: true,
|
||||
})
|
||||
@Public()
|
||||
async user(@Args('email') email: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
if (this.config.node.prod && this.config.affine.beta) {
|
||||
const hasEarlyAccess = await this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.catch(() => false);
|
||||
if (!hasEarlyAccess) {
|
||||
return new HttpException(
|
||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
|
||||
401
|
||||
);
|
||||
}
|
||||
}
|
||||
// TODO: need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.prisma.user
|
||||
.findUnique({
|
||||
where: { email },
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
if (user?.password) {
|
||||
const userResponse: UserType = user;
|
||||
userResponse.hasPassword = true;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
@@ -72,4 +148,45 @@ export class UserResolver {
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => DeleteAccount)
|
||||
async deleteAccount(@CurrentUser() user: UserType): Promise<DeleteAccount> {
|
||||
await this.prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
await this.prisma.session.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => AddToNewFeaturesWaitingList)
|
||||
async addToNewFeaturesWaitingList(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('type', {
|
||||
type: () => NewFeaturesKind,
|
||||
})
|
||||
type: NewFeaturesKind,
|
||||
@Args('email') email: string
|
||||
): Promise<AddToNewFeaturesWaitingList> {
|
||||
if (!user.email.endsWith('@toeverything.info')) {
|
||||
throw new ForbiddenException('You are not allowed to do this');
|
||||
}
|
||||
await this.prisma.newFeaturesWaitingList.create({
|
||||
data: {
|
||||
email,
|
||||
type,
|
||||
},
|
||||
});
|
||||
return {
|
||||
email,
|
||||
type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum NewFeaturesKind {
|
||||
EarlyAccess,
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Storage } from '@affine/storage';
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Inject,
|
||||
NotFoundException,
|
||||
@@ -8,20 +9,33 @@ import {
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import format from 'pretty-time';
|
||||
|
||||
import { StorageProvide } from '../../storage';
|
||||
import { trimGuid } from '../../utils/doc';
|
||||
import { Auth, CurrentUser, Publicable } from '../auth';
|
||||
import { DocManager } from '../doc';
|
||||
import { UserType } from '../users';
|
||||
import { PermissionService } from './permission';
|
||||
|
||||
@Controller('/api/workspaces')
|
||||
export class WorkspacesController {
|
||||
constructor(@Inject(StorageProvide) private readonly storage: Storage) {}
|
||||
constructor(
|
||||
@Inject(StorageProvide) private readonly storage: Storage,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly docManager: DocManager
|
||||
) {}
|
||||
|
||||
// get workspace blob
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@Get('/:id/blobs/:name')
|
||||
async blob(
|
||||
@Param('id') workspaceId: string,
|
||||
@Param('name') name: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const blob = await this.storage.blob(workspaceId, name);
|
||||
const blob = await this.storage.getBlob(workspaceId, name);
|
||||
|
||||
if (!blob) {
|
||||
throw new NotFoundException('Blob not found');
|
||||
@@ -33,4 +47,34 @@ export class WorkspacesController {
|
||||
|
||||
res.send(blob.data);
|
||||
}
|
||||
|
||||
// get doc binary
|
||||
@Get('/:id/docs/:guid')
|
||||
@Auth()
|
||||
@Publicable()
|
||||
async doc(
|
||||
@CurrentUser() user: UserType | undefined,
|
||||
@Param('id') ws: string,
|
||||
@Param('guid') guid: string,
|
||||
@Res() res: Response
|
||||
) {
|
||||
const start = process.hrtime();
|
||||
const id = trimGuid(ws, guid);
|
||||
if (
|
||||
// if a user has the permission
|
||||
!(await this.permission.isAccessible(ws, id, user?.id))
|
||||
) {
|
||||
throw new ForbiddenException('Permission denied');
|
||||
}
|
||||
|
||||
const update = await this.docManager.getLatestUpdate(ws, id);
|
||||
|
||||
if (!update) {
|
||||
throw new NotFoundException('Doc not found');
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/octet-stream');
|
||||
res.send(update);
|
||||
console.info('workspaces doc api: ', format(process.hrtime(start)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DocModule } from '../doc';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { PermissionService } from './permission';
|
||||
import { WorkspaceResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [WorkspaceResolver, PermissionService, WorkspacesController],
|
||||
imports: [DocModule.forFeature()],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [WorkspaceResolver, PermissionService],
|
||||
exports: [PermissionService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
export { WorkspaceType } from './resolver';
|
||||
export { InvitationType, WorkspaceType } from './resolver';
|
||||
|
||||
@@ -12,6 +12,7 @@ export class PermissionService {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
@@ -20,6 +21,38 @@ export class PermissionService {
|
||||
return data?.type as Permission;
|
||||
}
|
||||
|
||||
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
|
||||
if (user) {
|
||||
return await this.tryCheck(ws, user);
|
||||
} else {
|
||||
// check if this is a public workspace
|
||||
const count = await this.prisma.workspace.count({
|
||||
where: { id: ws, public: true },
|
||||
});
|
||||
if (count > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check whether this is a public subpage
|
||||
const workspace = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
userId: null,
|
||||
},
|
||||
});
|
||||
const subpages = workspace
|
||||
.map(ws => ws.subPageId)
|
||||
.filter((v): v is string => !!v);
|
||||
if (subpages.length > 0 && ws === id) {
|
||||
// rootDoc is always accessible when there is a public subpage
|
||||
return true;
|
||||
} else {
|
||||
// check if this is a public subpage
|
||||
return subpages.map(page => `space:${page}`).includes(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async check(
|
||||
ws: string,
|
||||
user: string,
|
||||
@@ -35,9 +68,21 @@ export class PermissionService {
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
// If the permission is read, we should check if the workspace is public
|
||||
if (permission === Permission.Read) {
|
||||
const data = await this.prisma.workspace.count({
|
||||
where: { id: ws, public: true },
|
||||
});
|
||||
|
||||
if (data > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await this.prisma.userWorkspacePermission.count({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
type: {
|
||||
@@ -46,30 +91,18 @@ export class PermissionService {
|
||||
},
|
||||
});
|
||||
|
||||
if (data > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the permission is read, we should check if the workspace is public
|
||||
if (permission === Permission.Read) {
|
||||
const data = await this.prisma.workspace.count({
|
||||
where: { id: ws, public: true },
|
||||
});
|
||||
|
||||
return data > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
return data > 0;
|
||||
}
|
||||
|
||||
async grant(
|
||||
ws: string,
|
||||
user: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
): Promise<string> {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
accepted: true,
|
||||
},
|
||||
@@ -105,22 +138,40 @@ export class PermissionService {
|
||||
].filter(Boolean) as Prisma.PrismaPromise<any>[]
|
||||
);
|
||||
|
||||
return p;
|
||||
return p.id;
|
||||
}
|
||||
|
||||
return this.prisma.userWorkspacePermission.create({
|
||||
data: {
|
||||
return this.prisma.userWorkspacePermission
|
||||
.create({
|
||||
data: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
type: permission,
|
||||
},
|
||||
})
|
||||
.then(p => p.id);
|
||||
}
|
||||
|
||||
async acceptById(ws: string, id: string) {
|
||||
const result = await this.prisma.userWorkspacePermission.updateMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId: ws,
|
||||
userId: user,
|
||||
type: permission,
|
||||
},
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
async accept(ws: string, user: string) {
|
||||
const result = await this.prisma.userWorkspacePermission.updateMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
accepted: false,
|
||||
},
|
||||
@@ -136,6 +187,67 @@ export class PermissionService {
|
||||
const result = await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: null,
|
||||
userId: user,
|
||||
type: {
|
||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
||||
not: Permission.Owner,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.count > 0;
|
||||
}
|
||||
|
||||
async isPageAccessible(ws: string, page: string, user?: string) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: page,
|
||||
userId: user,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.accepted || false;
|
||||
}
|
||||
|
||||
async grantPage(
|
||||
ws: string,
|
||||
page: string,
|
||||
user?: string,
|
||||
permission: Permission = Permission.Read
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: page,
|
||||
userId: user,
|
||||
},
|
||||
});
|
||||
|
||||
if (data) {
|
||||
return data.accepted;
|
||||
}
|
||||
|
||||
return this.prisma.userWorkspacePermission
|
||||
.create({
|
||||
data: {
|
||||
workspaceId: ws,
|
||||
subPageId: page,
|
||||
userId: user,
|
||||
// if provide user id, user need to accept the invitation
|
||||
accepted: user ? false : true,
|
||||
type: permission,
|
||||
},
|
||||
})
|
||||
.then(ret => ret.accepted);
|
||||
}
|
||||
|
||||
async revokePage(ws: string, page: string, user?: string) {
|
||||
const result = await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: ws,
|
||||
subPageId: page,
|
||||
userId: user,
|
||||
type: {
|
||||
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
OmitType,
|
||||
Parent,
|
||||
PartialType,
|
||||
PickType,
|
||||
@@ -19,20 +20,43 @@ import {
|
||||
import type { User, Workspace } from '@prisma/client';
|
||||
// @ts-expect-error graphql-upload is not typed
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { applyUpdate, Doc } from 'yjs';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { StorageProvide } from '../../storage';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth, CurrentUser } from '../auth';
|
||||
import { Auth, CurrentUser, Public } from '../auth';
|
||||
import { MailService } from '../auth/mailer';
|
||||
import { AuthService } from '../auth/service';
|
||||
import { UserType } from '../users/resolver';
|
||||
import { PermissionService } from './permission';
|
||||
import { Permission } from './types';
|
||||
import { defaultWorkspaceAvatar } from './utils';
|
||||
|
||||
registerEnumType(Permission, {
|
||||
name: 'Permission',
|
||||
description: 'User permission in workspace',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class InviteUserType extends OmitType(
|
||||
PartialType(UserType),
|
||||
['id'],
|
||||
ObjectType
|
||||
) {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => Permission, { description: 'User permission in workspace' })
|
||||
permission!: Permission;
|
||||
|
||||
@Field({ description: 'Invite id' })
|
||||
inviteId!: string;
|
||||
|
||||
@Field({ description: 'User accepted' })
|
||||
accepted!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceType implements Partial<Workspace> {
|
||||
@Field(() => ID)
|
||||
@@ -43,6 +67,34 @@ export class WorkspaceType implements Partial<Workspace> {
|
||||
|
||||
@Field({ description: 'Workspace created date' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
})
|
||||
members!: InviteUserType[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationWorkspaceType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Workspace name' })
|
||||
name!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
// nullable: true,
|
||||
description: 'Base64 encoded avatar',
|
||||
})
|
||||
avatar!: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class InvitationType {
|
||||
@Field({ description: 'Workspace information' })
|
||||
workspace!: InvitationWorkspaceType;
|
||||
@Field({ description: 'User information' })
|
||||
user!: UserType;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
@@ -59,6 +111,8 @@ export class UpdateWorkspaceInput extends PickType(
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class WorkspaceResolver {
|
||||
constructor(
|
||||
private readonly auth: AuthService,
|
||||
private readonly mailer: MailService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionProvider: PermissionService,
|
||||
@Inject(StorageProvide) private readonly storage: Storage
|
||||
@@ -69,7 +123,7 @@ export class WorkspaceResolver {
|
||||
complexity: 2,
|
||||
})
|
||||
async permission(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
// may applied in workspaces query
|
||||
@@ -99,6 +153,20 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [String], {
|
||||
description: 'Shared pages of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async sharedPages(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
return data.map(item => item.subPageId).filter(Boolean);
|
||||
}
|
||||
|
||||
@ResolveField(() => UserType, {
|
||||
description: 'Owner of workspace',
|
||||
complexity: 2,
|
||||
@@ -117,27 +185,46 @@ export class WorkspaceResolver {
|
||||
return data.user;
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserType], {
|
||||
@ResolveField(() => [InviteUserType], {
|
||||
description: 'Members of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async members(
|
||||
@CurrentUser() user: UserType,
|
||||
@Parent() workspace: WorkspaceType
|
||||
) {
|
||||
async members(@Parent() workspace: WorkspaceType) {
|
||||
const data = await this.prisma.userWorkspacePermission.findMany({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
accepted: true,
|
||||
userId: {
|
||||
not: user.id,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
return data.map(({ user }) => user);
|
||||
return data.map(({ id, accepted, type, user }) => ({
|
||||
...user,
|
||||
permission: type,
|
||||
inviteId: id,
|
||||
accepted,
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
description: 'Get is owner of workspace',
|
||||
complexity: 2,
|
||||
})
|
||||
async isOwner(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
const data = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.user?.id === user.id;
|
||||
}
|
||||
|
||||
@Query(() => [WorkspaceType], {
|
||||
@@ -163,6 +250,22 @@ export class WorkspaceResolver {
|
||||
});
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get public workspace by id',
|
||||
})
|
||||
@Public()
|
||||
async publicWorkspace(@Args('id') id: string) {
|
||||
const workspace = await this.prisma.workspace.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (workspace?.public) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
throw new NotFoundException("Workspace doesn't exist");
|
||||
}
|
||||
|
||||
@Query(() => WorkspaceType, {
|
||||
description: 'Get workspace by id',
|
||||
})
|
||||
@@ -181,7 +284,7 @@ export class WorkspaceResolver {
|
||||
description: 'Create a new workspace',
|
||||
})
|
||||
async createWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'init', type: () => GraphQLUpload })
|
||||
update: FileUpload
|
||||
) {
|
||||
@@ -215,7 +318,13 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
await this.storage.createWorkspace(workspace.id, buffer);
|
||||
await this.prisma.snapshot.create({
|
||||
data: {
|
||||
id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
blob: buffer,
|
||||
},
|
||||
});
|
||||
|
||||
return workspace;
|
||||
}
|
||||
@@ -224,11 +333,11 @@ export class WorkspaceResolver {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async updateWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
|
||||
{ id, ...updates }: UpdateWorkspaceInput
|
||||
) {
|
||||
await this.permissionProvider.check('id', user.id, Permission.Admin);
|
||||
await this.permissionProvider.check(id, user.id, Permission.Admin);
|
||||
|
||||
return this.prisma.workspace.update({
|
||||
where: {
|
||||
@@ -239,7 +348,7 @@ export class WorkspaceResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
|
||||
async deleteWorkspace(@CurrentUser() user: UserType, @Args('id') id: string) {
|
||||
await this.permissionProvider.check(id, user.id, Permission.Owner);
|
||||
|
||||
await this.prisma.workspace.delete({
|
||||
@@ -248,25 +357,30 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.userWorkspacePermission.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO:
|
||||
// delete all related data, like websocket connections, blobs, etc.
|
||||
await this.storage.deleteWorkspace(id);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.update.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
}),
|
||||
this.prisma.snapshot.deleteMany({
|
||||
where: {
|
||||
workspaceId: id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Mutation(() => String)
|
||||
async invite(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('email') email: string,
|
||||
@Args('permission', { type: () => Permission }) permission: Permission
|
||||
@Args('permission', { type: () => Permission }) permission: Permission,
|
||||
// TODO: add rate limit
|
||||
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
@@ -280,18 +394,122 @@ export class WorkspaceResolver {
|
||||
},
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException("User doesn't exist");
|
||||
if (target) {
|
||||
const originRecord = await this.prisma.userWorkspacePermission.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId: target.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (originRecord) {
|
||||
return originRecord.id;
|
||||
}
|
||||
|
||||
const inviteId = await this.permissionProvider.grant(
|
||||
workspaceId,
|
||||
target.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
return inviteId;
|
||||
} else {
|
||||
const user = await this.auth.createAnonymousUser(email);
|
||||
const inviteId = await this.permissionProvider.grant(
|
||||
workspaceId,
|
||||
user.id,
|
||||
permission
|
||||
);
|
||||
if (sendInviteMail) {
|
||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
||||
|
||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
||||
workspace: {
|
||||
id: inviteInfo.workspace.id,
|
||||
name: inviteInfo.workspace.name,
|
||||
avatar: inviteInfo.workspace.avatar,
|
||||
},
|
||||
user: {
|
||||
avatar: inviteInfo.user?.avatarUrl || '',
|
||||
name: inviteInfo.user?.name || '',
|
||||
},
|
||||
});
|
||||
}
|
||||
return inviteId;
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Query(() => InvitationType, {
|
||||
description: 'Update workspace',
|
||||
})
|
||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
||||
const permission =
|
||||
await this.prisma.userWorkspacePermission.findUniqueOrThrow({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await this.prisma.snapshot.findFirstOrThrow({
|
||||
where: {
|
||||
id: permission.workspaceId,
|
||||
workspaceId: permission.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
const doc = new Doc();
|
||||
|
||||
applyUpdate(doc, new Uint8Array(snapshot.blob));
|
||||
const metaJSON = doc.getMap('meta').toJSON();
|
||||
|
||||
const owner = await this.prisma.userWorkspacePermission.findFirstOrThrow({
|
||||
where: {
|
||||
workspaceId: permission.workspaceId,
|
||||
type: Permission.Owner,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
let avatar = '';
|
||||
|
||||
if (metaJSON.avatar) {
|
||||
const avatarBlob = await this.storage.getBlob(
|
||||
permission.workspaceId,
|
||||
metaJSON.avatar
|
||||
);
|
||||
avatar = avatarBlob?.data.toString('base64') || '';
|
||||
}
|
||||
|
||||
await this.permissionProvider.grant(workspaceId, target.id, permission);
|
||||
|
||||
return true;
|
||||
return {
|
||||
workspace: {
|
||||
name: metaJSON.name || '',
|
||||
avatar: avatar || defaultWorkspaceAvatar,
|
||||
id: permission.workspaceId,
|
||||
},
|
||||
user: owner.user,
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revoke(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('userId') userId: string
|
||||
) {
|
||||
@@ -300,9 +518,18 @@ export class WorkspaceResolver {
|
||||
return this.permissionProvider.revoke(workspaceId, userId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@Public()
|
||||
async acceptInviteById(
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('inviteId') inviteId: string
|
||||
) {
|
||||
return this.permissionProvider.acceptById(workspaceId, inviteId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async acceptInvite(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
return this.permissionProvider.accept(workspaceId, user.id);
|
||||
@@ -310,7 +537,7 @@ export class WorkspaceResolver {
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async leaveWorkspace(
|
||||
@CurrentUser() user: User,
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
@@ -318,14 +545,48 @@ export class WorkspaceResolver {
|
||||
return this.permissionProvider.revoke(workspaceId, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async sharePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
return this.permissionProvider.grantPage(workspaceId, pageId);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokePage(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('pageId') pageId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Admin);
|
||||
|
||||
return this.permissionProvider.revokePage(workspaceId, pageId);
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'List blobs of workspace',
|
||||
})
|
||||
async listBlobs(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
|
||||
return this.storage.listBlobs(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => String)
|
||||
async uploadBlob(
|
||||
@CurrentUser() user: User,
|
||||
async setBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args({ name: 'blob', type: () => GraphQLUpload })
|
||||
blob: FileUpload
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
await this.permissionProvider.check(workspaceId, user.id, Permission.Write);
|
||||
|
||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||
const stream = blob.createReadStream();
|
||||
@@ -341,4 +602,15 @@ export class WorkspaceResolver {
|
||||
|
||||
return this.storage.uploadBlob(workspaceId, buffer);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteBlob(
|
||||
@CurrentUser() user: UserType,
|
||||
@Args('workspaceId') workspaceId: string,
|
||||
@Args('hash') hash: string
|
||||
) {
|
||||
await this.permissionProvider.check(workspaceId, user.id);
|
||||
|
||||
return this.storage.deleteBlob(workspaceId, hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user