From b4bb57b2a5b57d4df043e2e92c0f71aee6ba7c52 Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 28 Apr 2023 07:02:05 +0800 Subject: [PATCH] feat(server): port resolvers to node server (#2026) Co-authored-by: Himself65 --- apps/server/.env.example | 1 + .../20230418212743_init/migration.sql | 67 ------ .../20230425035217_init/migration.sql | 59 +++++ apps/server/package.json | 7 +- apps/server/schema.prisma | 81 ++++--- apps/server/scripts/init-db.ts | 9 +- apps/server/src/app.ts | 1 + apps/server/src/config/def.ts | 4 + apps/server/src/config/default.ts | 1 + apps/server/src/global.d.ts | 5 + apps/server/src/modules/auth/guard.ts | 82 +++++++ apps/server/src/modules/auth/index.ts | 12 + apps/server/src/modules/auth/resolver.ts | 54 +++++ apps/server/src/modules/auth/service.ts | 45 ++++ apps/server/src/modules/index.ts | 3 +- apps/server/src/modules/users/resolver.ts | 22 +- apps/server/src/modules/workspaces/index.ts | 5 +- .../src/modules/workspaces/permission.ts | 134 +++++++++++ .../server/src/modules/workspaces/resolver.ts | 227 ++++++++++++++---- apps/server/src/modules/workspaces/types.ts | 6 + apps/server/src/prisma/index.ts | 1 + apps/server/src/tests/app.e2e.ts | 44 +++- apps/server/src/utils/nestjs.ts | 57 +++++ apps/server/tsconfig.node.json | 2 +- tsconfig.json | 3 + yarn.lock | 66 ++++- 26 files changed, 807 insertions(+), 191 deletions(-) delete mode 100644 apps/server/migrations/20230418212743_init/migration.sql create mode 100644 apps/server/migrations/20230425035217_init/migration.sql create mode 100644 apps/server/src/global.d.ts create mode 100644 apps/server/src/modules/auth/guard.ts create mode 100644 apps/server/src/modules/auth/index.ts create mode 100644 apps/server/src/modules/auth/resolver.ts create mode 100644 apps/server/src/modules/auth/service.ts create mode 100644 apps/server/src/modules/workspaces/permission.ts create mode 100644 apps/server/src/modules/workspaces/types.ts create mode 100644 apps/server/src/utils/nestjs.ts diff --git a/apps/server/.env.example b/apps/server/.env.example index 46dafb2903..a81ef77001 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -1 +1,2 @@ +SECRET_KEY="secret" DATABASE_URL="postgresql://affine@localhost:5432/affine" diff --git a/apps/server/migrations/20230418212743_init/migration.sql b/apps/server/migrations/20230418212743_init/migration.sql deleted file mode 100644 index da95ce145a..0000000000 --- a/apps/server/migrations/20230418212743_init/migration.sql +++ /dev/null @@ -1,67 +0,0 @@ --- CreateTable -CREATE TABLE "google_users" ( - "id" VARCHAR NOT NULL, - "user_id" VARCHAR NOT NULL, - "google_id" VARCHAR NOT NULL, - - CONSTRAINT "google_users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "permissions" ( - "id" VARCHAR NOT NULL, - "workspace_id" VARCHAR NOT NULL, - "user_id" VARCHAR, - "user_email" TEXT, - "type" SMALLINT NOT NULL, - "accepted" BOOLEAN NOT NULL DEFAULT false, - "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "permissions_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "seaql_migrations" ( - "version" VARCHAR NOT NULL, - "applied_at" BIGINT NOT NULL, - - CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version") -); - --- CreateTable -CREATE TABLE "users" ( - "id" VARCHAR NOT NULL, - "name" VARCHAR NOT NULL, - "email" VARCHAR NOT NULL, - "avatar_url" VARCHAR, - "token_nonce" SMALLINT DEFAULT 0, - "password" VARCHAR, - "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "users_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "workspaces" ( - "id" VARCHAR NOT NULL, - "public" BOOLEAN NOT NULL, - "type" SMALLINT NOT NULL, - "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id"); - --- CreateIndex -CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); - --- AddForeignKey -ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/migrations/20230425035217_init/migration.sql b/apps/server/migrations/20230425035217_init/migration.sql new file mode 100644 index 0000000000..cbe1d2b568 --- /dev/null +++ b/apps/server/migrations/20230425035217_init/migration.sql @@ -0,0 +1,59 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL, + "email" VARCHAR NOT NULL, + "token_nonce" SMALLINT NOT NULL DEFAULT 0, + "avatar_url" VARCHAR, + "password" VARCHAR, + "fulfilled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "workspaces" ( + "id" VARCHAR NOT NULL, + "public" BOOLEAN NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "connected_accounts" ( + "id" VARCHAR NOT NULL, + "user_id" TEXT NOT NULL, + "provider" VARCHAR NOT NULL, + "provider_user_id" VARCHAR NOT NULL, + + CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_workspace_permissions" ( + "id" VARCHAR NOT NULL, + "workspace_id" VARCHAR NOT NULL, + "entity_id" VARCHAR NOT NULL, + "type" SMALLINT NOT NULL, + "accepted" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id"); + +-- AddForeignKey +ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/server/package.json b/apps/server/package.json index 77927ed122..b9b80edf30 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,8 @@ "scripts": { "dev": "nodemon ./src/index.ts", "test": "yarn exec ts-node-esm ./scripts/run-test.ts all", - "test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all" + "test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all", + "postinstall": "prisma generate" }, "dependencies": { "@apollo/server": "^4.7.0", @@ -21,8 +22,10 @@ "@nestjs/platform-express": "^9.4.0", "@prisma/client": "^4.13.0", "dotenv": "^16.0.3", + "express": "^4.18.2", "graphql": "^16.6.0", "graphql-type-json": "^0.3.2", + "jsonwebtoken": "^9.0.0", "lodash-es": "^4.17.21", "prisma": "^4.13.0", "reflect-metadata": "^0.1.13", @@ -30,6 +33,8 @@ }, "devDependencies": { "@nestjs/testing": "^9.4.0", + "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.1", "@types/lodash-es": "^4.17.7", "@types/node": "^18.16.2", "@types/supertest": "^2.0.12", diff --git a/apps/server/schema.prisma b/apps/server/schema.prisma index 6c2d31ff7c..290774c807 100644 --- a/apps/server/schema.prisma +++ b/apps/server/schema.prisma @@ -7,46 +7,57 @@ generator client { provider = "prisma-client-js" } -model google_users { - id String @id @db.VarChar - user_id String @db.VarChar - google_id String @unique @db.VarChar - users users @relation(fields: [user_id], references: [id], onDelete: Cascade) +model User { + id String @id @default(uuid()) @db.VarChar + name String @db.VarChar + email String @unique @db.VarChar + tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt + avatarUrl String? @map("avatar_url") @db.VarChar + /// Available if user signed up through OAuth providers + password String? @db.VarChar + /// User may created by email collobration invitation before signup. + /// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up + /// This implementation is convenient for handing unregistered user permissoin + fulfilled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + connectedAccounts ConnectedAccount[] + workspaces UserWorkspacePermission[] + + @@map("users") } -model permissions { - id String @id @db.VarChar - workspace_id String @db.VarChar - user_id String? @db.VarChar - user_email String? - type Int @db.SmallInt - accepted Boolean @default(false) - created_at DateTime? @default(now()) @db.Timestamptz(6) - users users? @relation(fields: [user_id], references: [id], onDelete: Cascade) - workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade) +model Workspace { + id String @id @default(uuid()) @db.VarChar + public Boolean + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + users UserWorkspacePermission[] + + @@map("workspaces") } -model seaql_migrations { - version String @id @db.VarChar - applied_at BigInt +model ConnectedAccount { + id String @id @default(uuid()) @db.VarChar + userId String @map("user_id") + /// the general provider name, e.g. google, github, facebook + provider String @db.VarChar + /// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub + providerUserId String @unique @map("provider_user_id") @db.VarChar + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("connected_accounts") } -model users { - id String @id @db.VarChar - name String @db.VarChar - email String @unique @db.VarChar - avatar_url String? @db.VarChar - token_nonce Int? @default(0) @db.SmallInt - password String? @db.VarChar - created_at DateTime? @default(now()) @db.Timestamptz(6) - google_users google_users[] - permissions permissions[] -} +model UserWorkspacePermission { + id String @id @default(uuid()) @db.VarChar + workspaceId String @map("workspace_id") @db.VarChar + userId String @map("entity_id") @db.VarChar + /// Read/Write/Admin/Owner + type Int @db.SmallInt + /// Whether the permission invitation is accepted by the user + accepted Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) -model workspaces { - id String @id @db.VarChar - public Boolean - type Int @db.SmallInt - created_at DateTime? @default(now()) @db.Timestamptz(6) - permissions permissions[] + @@map("user_workspace_permissions") } diff --git a/apps/server/scripts/init-db.ts b/apps/server/scripts/init-db.ts index bc3bf73f68..3bb8bcd662 100644 --- a/apps/server/scripts/init-db.ts +++ b/apps/server/scripts/init-db.ts @@ -1,15 +1,10 @@ -import { randomUUID } from 'node:crypto'; - import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' }; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - await prisma.users.create({ - data: { - id: randomUUID(), - ...userA, - }, + await prisma.user.create({ + data: userA, }); } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 0a3bec64da..576befd384 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,4 @@ +/// import { Module } from '@nestjs/common'; import { ConfigModule } from './config'; diff --git a/apps/server/src/config/def.ts b/apps/server/src/config/def.ts index 19e2f6b363..404a458b2f 100644 --- a/apps/server/src/config/def.ts +++ b/apps/server/src/config/def.ts @@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) { */ export interface AFFiNEConfig { ENV_MAP: Record; + /** + * Application sign key secret + */ + readonly secret: string; /** * System version */ diff --git a/apps/server/src/config/default.ts b/apps/server/src/config/default.ts index 1e186a775b..9bdf1531bf 100644 --- a/apps/server/src/config/default.ts +++ b/apps/server/src/config/default.ts @@ -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', diff --git a/apps/server/src/global.d.ts b/apps/server/src/global.d.ts new file mode 100644 index 0000000000..0d2d7c791f --- /dev/null +++ b/apps/server/src/global.d.ts @@ -0,0 +1,5 @@ +declare namespace Express { + interface Request { + user?: import('@prisma/client').User | null; + } +} diff --git a/apps/server/src/modules/auth/guard.ts b/apps/server/src/modules/auth/guard.ts new file mode 100644 index 0000000000..1b70fb5705 --- /dev/null +++ b/apps/server/src/modules/auth/guard.ts @@ -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); +}; diff --git a/apps/server/src/modules/auth/index.ts b/apps/server/src/modules/auth/index.ts new file mode 100644 index 0000000000..64badfe801 --- /dev/null +++ b/apps/server/src/modules/auth/index.ts @@ -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'; diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts new file mode 100644 index 0000000000..8c1e1aadad --- /dev/null +++ b/apps/server/src/modules/auth/resolver.ts @@ -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; + } +} diff --git a/apps/server/src/modules/auth/service.ts b/apps/server/src/modules/auth/service.ts new file mode 100644 index 0000000000..24ec6de7b1 --- /dev/null +++ b/apps/server/src/modules/auth/service.ts @@ -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; + +@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; + } +} diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index cd5cde7cee..1daa68cc3e 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -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]; diff --git a/apps/server/src/modules/users/resolver.ts b/apps/server/src/modules/users/resolver.ts index c92114acdb..108b6549e6 100644 --- a/apps/server/src/modules/users/resolver.ts +++ b/apps/server/src/modules/users/resolver.ts @@ -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 { @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 }, }); } diff --git a/apps/server/src/modules/workspaces/index.ts b/apps/server/src/modules/workspaces/index.ts index b60c99ab41..9100936751 100644 --- a/apps/server/src/modules/workspaces/index.ts +++ b/apps/server/src/modules/workspaces/index.ts @@ -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'; diff --git a/apps/server/src/modules/workspaces/permission.ts b/apps/server/src/modules/workspaces/permission.ts new file mode 100644 index 0000000000..5a11e4013d --- /dev/null +++ b/apps/server/src/modules/workspaces/permission.ts @@ -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[] + ); + + 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; + } +} diff --git a/apps/server/src/modules/workspaces/resolver.ts b/apps/server/src/modules/workspaces/resolver.ts index 12f7590a1f..70df939144 100644 --- a/apps/server/src/modules/workspaces/resolver.ts +++ b/apps/server/src/modules/workspaces/resolver.ts @@ -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 { @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; + } } diff --git a/apps/server/src/modules/workspaces/types.ts b/apps/server/src/modules/workspaces/types.ts new file mode 100644 index 0000000000..a86fcb6c2b --- /dev/null +++ b/apps/server/src/modules/workspaces/types.ts @@ -0,0 +1,6 @@ +export enum Permission { + Read = 0, + Write = 1, + Admin = 10, + Owner = 99, +} diff --git a/apps/server/src/prisma/index.ts b/apps/server/src/prisma/index.ts index 37d50415bc..b3f3b330d8 100644 --- a/apps/server/src/prisma/index.ts +++ b/apps/server/src/prisma/index.ts @@ -8,3 +8,4 @@ import { PrismaService } from './service'; exports: [PrismaService], }) export class PrismaModule {} +export { PrismaService } from './service'; diff --git a/apps/server/src/tests/app.e2e.ts b/apps/server/src/tests/app.e2e.ts index 965cb4e5c7..b9e8485f8e 100644 --- a/apps/server/src/tests/app.e2e.ts +++ b/apps/server/src/tests/app.e2e.ts @@ -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 } } `, diff --git a/apps/server/src/utils/nestjs.ts b/apps/server/src/utils/nestjs.ts new file mode 100644 index 0000000000..f1f18beefe --- /dev/null +++ b/apps/server/src/utils/nestjs.ts @@ -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()) { + 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(), + res: http.getResponse(), + }; + } + default: + throw new Error('Unknown context type for getting request and response'); + } +} + +export function getRequestResponseFromHost(host: ArgumentsHost) { + switch (host.getType()) { + 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(), + res: http.getResponse(), + }; + } + default: + throw new Error('Unknown host type for getting request and response'); + } +} + +export function getRequestFromHost(host: ArgumentsHost) { + return getRequestResponseFromHost(host).req; +} diff --git a/apps/server/tsconfig.node.json b/apps/server/tsconfig.node.json index b9e486d423..4651f71bc1 100644 --- a/apps/server/tsconfig.node.json +++ b/apps/server/tsconfig.node.json @@ -7,5 +7,5 @@ "moduleResolution": "Node", "allowSyntheticDefaultImports": true }, - "include": ["./scripts", "package.json"] + "include": ["scripts", "package.json"] } diff --git a/tsconfig.json b/tsconfig.json index 2fc98ec3d0..a4f0c1bf8c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -46,6 +46,9 @@ { "path": "./apps/web" }, + { + "path": "./apps/server" + }, { "path": "./packages/component" }, diff --git a/yarn.lock b/yarn.lock index a5231f9ad1..78abd649e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -248,13 +248,17 @@ __metadata: "@nestjs/platform-express": ^9.4.0 "@nestjs/testing": ^9.4.0 "@prisma/client": ^4.13.0 + "@types/express": ^4.17.17 + "@types/jsonwebtoken": ^9.0.1 "@types/lodash-es": ^4.17.7 "@types/node": ^18.16.2 "@types/supertest": ^2.0.12 c8: ^7.13.0 dotenv: ^16.0.3 + express: ^4.18.2 graphql: ^16.6.0 graphql-type-json: ^0.3.2 + jsonwebtoken: ^9.0.0 lodash-es: ^4.17.21 nodemon: ^2.0.22 prisma: ^4.13.0 @@ -8269,7 +8273,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:^4.17.13, @types/express@npm:^4.7.0": +"@types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.7.0": version: 4.17.17 resolution: "@types/express@npm:4.17.17" dependencies: @@ -8438,6 +8442,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonwebtoken@npm:^9.0.1": + version: 9.0.1 + resolution: "@types/jsonwebtoken@npm:9.0.1" + dependencies: + "@types/node": "*" + checksum: a7f0925e9a42ad3ae970364c63c5986d40da5c83d51d3f4e624eb0f064a380376f9e3fb3f2f837390a9ab80143f5d75fd51866da30e110f6b486a3379e1c768f + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.4": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -10609,6 +10622,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab + languageName: node + linkType: hard + "buffer-equal@npm:^1.0.0": version: 1.0.1 resolution: "buffer-equal@npm:1.0.1" @@ -12575,6 +12595,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: ^5.0.1 + checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -13749,7 +13778,7 @@ __metadata: languageName: node linkType: hard -"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3": +"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -17588,6 +17617,18 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:^9.0.0": + version: 9.0.0 + resolution: "jsonwebtoken@npm:9.0.0" + dependencies: + jws: ^3.2.2 + lodash: ^4.17.21 + ms: ^2.1.1 + semver: ^7.3.8 + checksum: b9181cecf9df99f1dc0253f91ba000a1aa4d91f5816d1608c0dba61a5623726a0bfe200b51df25de18c1a6000825d231ad7ce2788aa54fd48dcb760ad9eb9514 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.3": version: 3.3.3 resolution: "jsx-ast-utils@npm:3.3.3" @@ -17605,6 +17646,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: ^5.0.1 + checksum: ff30ea7c2dcc61f3ed2098d868bf89d43701605090c5b21b5544b512843ec6fd9e028381a4dda466cbcdb885c2d1150f7c62e7168394ee07941b4098e1035e2f + languageName: node + linkType: hard + +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: ^1.4.1 + safe-buffer: ^5.0.1 + checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.2": version: 4.5.2 resolution: "keyv@npm:4.5.2"