feat(server): port resolvers to node server (#2026)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
liuyi
2023-04-28 07:02:05 +08:00
committed by GitHub
parent 3df3498523
commit b4bb57b2a5
26 changed files with 807 additions and 191 deletions

View File

@@ -1,3 +1,4 @@
/// <reference types="./global.d.ts" />
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';

View File

@@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
*/
export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* Application sign key secret
*/
readonly secret: string;
/**
* System version
*/

View File

@@ -2,6 +2,7 @@ import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
secret: 'secret',
version: pkg.version,
ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development',

5
apps/server/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace Express {
interface Request {
user?: import('@prisma/client').User | null;
}
}

View File

@@ -0,0 +1,82 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UseGuards,
} from '@nestjs/common';
import { PrismaService } from '../../prisma';
import { getRequestResponseFromContext } from '../../utils/nestjs';
import { AuthService } from './service';
export function getUserFromContext(context: ExecutionContext) {
const req = getRequestResponseFromContext(context).req;
return req.user;
}
/**
* Used to fetch current user from the request context.
*
* > The user may be undefined if authorization token is not provided.
*
* @example
*
* ```typescript
* // Graphql Query
* \@Query(() => UserType)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*
* ```typescript
* // HTTP Controller
* \@Get('/user)
* user(@CurrentUser() user?: User) {
* return user;
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => {
return getUserFromContext(context);
}
);
@Injectable()
class AuthGuard implements CanActivate {
constructor(private auth: AuthService, private prisma: PrismaService) {}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const token = req.headers.authorization;
if (!token) {
return false;
}
const claims = this.auth.verify(token);
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
return !!req.user;
}
}
/**
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
*
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
* fast throw if user is not logged in.
*
* @example
*
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: User) {
* return user;
* }
* ```
*/
export const Auth = () => {
return UseGuards(AuthGuard);
};

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@Global()
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
})
export class AuthModule {}
export * from './guard';

View File

@@ -0,0 +1,54 @@
import { ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
Field,
Mutation,
ObjectType,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { Request } from 'express';
import { UserType } from '../users/resolver';
import { CurrentUser } from './guard';
import { AuthService } from './service';
@ObjectType()
export class TokenType {
@Field()
token!: string;
@Field()
refresh!: string;
}
@Resolver(() => UserType)
export class AuthResolver {
constructor(private auth: AuthService) {}
@ResolveField(() => TokenType)
token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
if (user !== currentUser) {
throw new ForbiddenException();
}
return {
token: this.auth.sign(user),
// TODO: impl
refresh: '',
};
}
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string
) {
const user = await this.auth.signIn(email, password);
ctx.req.user = user;
return user;
}
}

View File

@@ -0,0 +1,45 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { User } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
@Injectable()
export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) {
return jwt.sign(user, this.config.secret);
}
verify(token: string) {
try {
const claims = jwt.verify(token, this.config.secret) as UserClaim;
return claims;
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
}
async signIn(email: string, password: string) {
const user = await this.prisma.user.findFirst({
where: {
email,
password,
},
});
if (!user) {
throw new BadRequestException('Invalid email or password');
}
return user;
}
}

View File

@@ -1,4 +1,5 @@
import { AuthModule } from './auth';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [WorkspaceModule, UsersModule];
export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];

View File

@@ -1,36 +1,36 @@
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
import type { users } from '@prisma/client';
import type { User } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
@ObjectType()
export class User implements users {
export class UserType implements Partial<User> {
@Field(() => ID)
id!: string;
@Field({ description: 'User name' })
name!: string;
@Field({ description: 'User email' })
email!: string;
@Field({ description: 'User password', nullable: true })
password!: string;
@Field({ description: 'User avatar url', nullable: true })
avatar_url!: string;
@Field({ description: 'User token nonce', nullable: true })
token_nonce!: number;
avatarUrl!: string;
@Field({ description: 'User created date', nullable: true })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => User)
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly prisma: PrismaService) {}
@Query(() => User, {
@Query(() => UserType, {
name: 'user',
description: 'Get user by email',
})
async user(@Args('email') email: string) {
return this.prisma.users.findUnique({
return this.prisma.user.findUnique({
where: { email },
});
}

View File

@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver],
providers: [WorkspaceResolver, PermissionService],
exports: [PermissionService],
})
export class WorkspaceModule {}
export { WorkspaceType } from './resolver';

View File

@@ -0,0 +1,134 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma';
import { Permission } from './types';
@Injectable()
export class PermissionService {
constructor(private readonly prisma: PrismaService) {}
async get(ws: string, user: string) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
return data?.type as Permission;
}
async check(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
if (!(await this.tryCheck(ws, user, permission))) {
throw new ForbiddenException('Permission denied');
}
}
async tryCheck(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.count({
where: {
workspaceId: ws,
userId: user,
accepted: true,
type: {
gte: permission,
},
},
});
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;
}
async grant(
ws: string,
user: string,
permission: Permission = Permission.Read
) {
const data = await this.prisma.userWorkspacePermission.findFirst({
where: {
workspaceId: ws,
userId: user,
accepted: true,
},
});
if (data) {
const [p] = await this.prisma.$transaction(
[
this.prisma.userWorkspacePermission.update({
where: {
id: data.id,
},
data: {
type: permission,
},
}),
// If the new permission is owner, we need to revoke old owner
permission === Permission.Owner
? this.prisma.userWorkspacePermission.updateMany({
where: {
workspaceId: ws,
type: Permission.Owner,
userId: {
not: user,
},
},
data: {
type: Permission.Admin,
},
})
: null,
].filter(Boolean) as Prisma.PrismaPromise<any>[]
);
return p;
}
return this.prisma.userWorkspacePermission.create({
data: {
workspaceId: ws,
userId: user,
type: permission,
},
});
}
async revoke(ws: string, user: string) {
const result = await this.prisma.userWorkspacePermission.deleteMany({
where: {
workspaceId: ws,
userId: user,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: Permission.Owner,
},
},
});
return result.count > 0;
}
}

View File

@@ -1,85 +1,206 @@
import { randomUUID } from 'node:crypto';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
ID,
InputType,
Int,
Mutation,
ObjectType,
Parent,
PartialType,
PickType,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { workspaces } from '@prisma/client';
import type { User, Workspace } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
import { PrismaService } from '../../prisma';
import { Auth, CurrentUser } from '../auth';
import { UserType } from '../users/resolver';
import { PermissionService } from './permission';
import { Permission } from './types';
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
registerEnumType(WorkspaceType, {
name: 'WorkspaceType',
description: 'Workspace type',
valuesMap: {
Normal: {
description: 'Normal workspace',
},
Private: {
description: 'Private workspace',
},
},
registerEnumType(Permission, {
name: 'Permission',
description: 'User permission in workspace',
});
@ObjectType()
export class Workspace implements workspaces {
export class WorkspaceType implements Partial<Workspace> {
@Field(() => ID)
id!: string;
@Field({ description: 'is Public workspace' })
public!: boolean;
@Field(() => WorkspaceType, { description: 'Workspace type' })
type!: WorkspaceType;
@Field({ description: 'Workspace created date' })
created_at!: Date;
createdAt!: Date;
}
@Resolver(() => Workspace)
@InputType()
export class UpdateWorkspaceInput extends PickType(
PartialType(WorkspaceType),
['public'],
InputType
) {
@Field(() => ID)
id!: string;
}
@Auth()
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly permissionProvider: PermissionService
) {}
// debug only query should be removed
@Query(() => [Workspace], {
name: 'workspaces',
description: 'Get all workspaces',
@ResolveField(() => Permission, {
description: 'Permission of current signed in user in workspace',
complexity: 2,
})
async workspaces() {
return this.prisma.workspaces.findMany();
async permission(
@CurrentUser() user: User,
@Parent() workspace: WorkspaceType
) {
// may applied in workspaces query
if ('permission' in workspace) {
return workspace.permission;
}
const permission = this.permissionProvider.get(workspace.id, user.id);
if (!permission) {
throw new ForbiddenException();
}
return permission;
}
@Query(() => Workspace, {
name: 'workspace',
description: 'Get workspace by id',
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
async workspace(@Args('id') id: string) {
return this.prisma.workspaces.findUnique({
where: { id },
});
}
// create workspace
@Mutation(() => Workspace, {
name: 'createWorkspace',
description: 'Create workspace',
})
async createWorkspace() {
return this.prisma.workspaces.create({
data: {
id: randomUUID(),
type: WorkspaceType.Private,
public: false,
created_at: new Date(),
memberCount(@Parent() workspace: WorkspaceType) {
return this.prisma.userWorkspacePermission.count({
where: {
workspaceId: workspace.id,
accepted: true,
},
});
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
where: {
workspaceId: workspace.id,
type: Permission.Owner,
},
include: {
user: true,
},
});
return data.user;
}
@Query(() => [WorkspaceType], {
description: 'Get all accessible workspaces for current user',
complexity: 2,
})
async workspaces(@CurrentUser() user: User) {
const data = await this.prisma.userWorkspacePermission.findMany({
where: {
userId: user.id,
accepted: true,
},
include: {
workspace: true,
},
});
return data.map(({ workspace, type }) => {
return {
...workspace,
permission: type,
};
});
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id);
const workspace = await this.prisma.workspace.findUnique({ where: { id } });
if (!workspace) {
throw new NotFoundException("Workspace doesn't exist");
}
return workspace;
}
@Mutation(() => WorkspaceType, {
description: 'Create a new workspace',
})
async createWorkspace(@CurrentUser() user: User) {
return this.prisma.workspace.create({
data: {
public: false,
users: {
create: {
type: Permission.Owner,
user: {
connect: {
id: user.id,
},
},
accepted: true,
},
},
},
});
}
@Mutation(() => WorkspaceType, {
description: 'Update workspace',
})
async updateWorkspace(
@CurrentUser() user: User,
@Args({ name: 'input', type: () => UpdateWorkspaceInput })
{ id, ...updates }: UpdateWorkspaceInput
) {
await this.permissionProvider.check('id', user.id, Permission.Admin);
return this.prisma.workspace.update({
where: {
id,
},
data: updates,
});
}
@Mutation(() => Boolean)
async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
await this.permissionProvider.check(id, user.id, Permission.Owner);
await this.prisma.workspace.delete({
where: {
id,
},
});
// TODO:
// delete all related data, like websocket connections, blobs, etc.
return true;
}
}

View File

@@ -0,0 +1,6 @@
export enum Permission {
Read = 0,
Write = 1,
Admin = 10,
Owner = 99,
}

View File

@@ -8,3 +8,4 @@ import { PrismaService } from './service';
exports: [PrismaService],
})
export class PrismaModule {}
export { PrismaService } from './service';

View File

@@ -34,22 +34,46 @@ describe('AppModule', () => {
.post(gql)
.send({
query: `
query {
error
}
`,
query {
error
}
`,
})
.expect(400);
let token;
await request(app.getHttpServer())
.post(gql)
.send({
query: `
mutation {
signIn(email: "alex.yang@example.org", password: "123456") {
token {
token
}
}
}
`,
})
.expect(200)
.expect(res => {
ok(
typeof res.body.data.signIn.token.token === 'string',
'res.body.data.signIn.token.token is not a string'
);
token = res.body.data.signIn.token.token;
});
await request(app.getHttpServer())
.post(gql)
.set({ Authorization: token })
.send({
query: `
mutation {
createWorkspace {
id
type
public
created_at
createdAt
}
}
`,
@@ -64,16 +88,12 @@ describe('AppModule', () => {
typeof res.body.data.createWorkspace.id === 'string',
'res.body.data.createWorkspace.id is not a string'
);
ok(
typeof res.body.data.createWorkspace.type === 'string',
'res.body.data.createWorkspace.type is not a string'
);
ok(
typeof res.body.data.createWorkspace.public === 'boolean',
'res.body.data.createWorkspace.public is not a boolean'
);
ok(
typeof res.body.data.createWorkspace.created_at === 'string',
typeof res.body.data.createWorkspace.createdAt === 'string',
'res.body.data.createWorkspace.created_at is not a string'
);
});
@@ -87,7 +107,7 @@ describe('AppModule', () => {
query {
user(email: "alex.yang@example.org") {
email
avatar_url
avatarUrl
}
}
`,

View File

@@ -0,0 +1,57 @@
import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
import {
GqlArgumentsHost,
GqlContextType,
GqlExecutionContext,
} from '@nestjs/graphql';
import { Request, Response } from 'express';
export function getRequestResponseFromContext(context: ExecutionContext) {
switch (context.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlExecutionContext.create(context).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = context.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown context type for getting request and response');
}
}
export function getRequestResponseFromHost(host: ArgumentsHost) {
switch (host.getType<GqlContextType>()) {
case 'graphql': {
const gqlContext = GqlArgumentsHost.create(host).getContext<{
req: Request;
}>();
return {
req: gqlContext.req,
res: gqlContext.req.res!,
};
}
case 'http': {
const http = host.switchToHttp();
return {
req: http.getRequest<Request>(),
res: http.getResponse<Response>(),
};
}
default:
throw new Error('Unknown host type for getting request and response');
}
}
export function getRequestFromHost(host: ArgumentsHost) {
return getRequestResponseFromHost(host).req;
}