Skip to content

Commit

Permalink
Feature/#152 - 로그인한 사람들이 주식 채팅방에 참여할 수 있다. (#157)
Browse files Browse the repository at this point in the history
  • Loading branch information
xjfcnfw3 authored Nov 15, 2024
2 parents 888dfee + 6661888 commit a4ab235
Show file tree
Hide file tree
Showing 21 changed files with 306 additions and 20 deletions.
6 changes: 5 additions & 1 deletion packages/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WinstonModule } from 'nest-winston';
import { AuthModule } from '@/auth/auth.module';
import { logger } from '@/configs/logger.config';
import { SessionModule } from '@/auth/session.module';
import { ChatModule } from '@/chat/chat.module';
import {
typeormDevelopConfig,
typeormProductConfig,
} from '@/configs/devTypeormConfig';
import { logger } from '@/configs/logger.config';
import { StockModule } from '@/stock/stock.module';
import { UserModule } from '@/user/user.module';

Expand All @@ -23,6 +25,8 @@ import { UserModule } from '@/user/user.module';
),
WinstonModule.forRoot(logger),
AuthModule,
ChatModule,
SessionModule,
],
controllers: [],
providers: [],
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { GoogleAuthController } from '@/auth/googleAuth.controller';
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { GoogleStrategy } from '@/auth/passport/google.strategy';
import { SessionSerializer } from '@/auth/passport/session.serializer';
import { GoogleAuthController } from '@/auth/google/googleAuth.controller';
import { GoogleAuthService } from '@/auth/google/googleAuth.service';
import { GoogleStrategy } from '@/auth/google/strategy/google.strategy';
import { SessionSerializer } from '@/auth/session/session.serializer';
import { UserModule } from '@/user/user.module';

@Module({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { GoogleAuthGuard } from '@/auth/guard/google.guard';
import { User } from '@/user/domain/user.entity';
import { Request, Response } from 'express';
import { GoogleAuthGuard } from '@/auth/google/guard/google.guard';

@ApiTags('Auth')
@Controller('auth/google')
Expand All @@ -21,9 +20,8 @@ export class GoogleAuthController {

@Get('/redirect')
@UseGuards(GoogleAuthGuard)
async handleRedirect(@Req() request: Request) {
const user = request.user as User;
return { nickname: user.nickname, email: user.email };
async handleRedirect(@Res() response: Response) {
response.redirect('/');
}

@ApiOperation({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { OauthUserInfo } from '@/auth/passport/google.strategy';
import { GoogleAuthService } from '@/auth/google/googleAuth.service';
import { OauthUserInfo } from '@/auth/google/strategy/google.strategy';
import { OauthType } from '@/user/domain/ouathType';
import { Role } from '@/user/domain/role';
import { User } from '@/user/domain/user.entity';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { OauthUserInfo } from '@/auth/passport/google.strategy';
import { OauthUserInfo } from '@/auth/google/strategy/google.strategy';
import { UserService } from '@/user/user.service';

@Injectable()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ export class GoogleAuthGuard extends AuthGuard('google') {
await super.logIn(request);
return isActivate;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
import { GoogleAuthService } from '@/auth/googleAuth.service';
import { GoogleAuthService } from '@/auth/google/googleAuth.service';
import { OauthType } from '@/user/domain/ouathType';
import { Logger } from 'winston';

Expand Down
17 changes: 17 additions & 0 deletions packages/backend/src/auth/session.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { MemoryStore } from 'express-session';

export const MEMORY_STORE = 'memoryStore';

@Module({
providers: [
{
provide: MEMORY_STORE,
useFactory: () => {
return new MemoryStore();
},
},
],
exports: [MEMORY_STORE],
})
export class SessionModule {}
31 changes: 31 additions & 0 deletions packages/backend/src/auth/session/cookieParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as crypto from 'node:crypto';
import { WsException } from '@nestjs/websockets';
import * as cookie from 'cookie';
import { Socket } from 'socket.io';
import { sessionConfig } from '@/configs/session.config';

export const websocketCookieParse = (socket: Socket) => {
if (!socket.request.headers.cookie) {
throw new WsException('not found cookie');
}
const cookies = cookie.parse(socket.request.headers.cookie);
const sid = cookies['connect.sid'];
return getSessionIdFromCookie(sid);
};

const getSessionIdFromCookie = (cookieValue: string) => {
if (cookieValue.startsWith('s:')) {
const [id, signature] = cookieValue.slice(2).split('.');
const expectedSignature = crypto
.createHmac('sha256', sessionConfig.secret)
.update(id)
.digest('base64')
.replace(/=+$/, '');

if (expectedSignature === signature) {
return id;
}
throw new WsException('Invalid cookie signature');
}
throw new WsException('Invalid cookie format');
};
45 changes: 45 additions & 0 deletions packages/backend/src/auth/session/webSocketSession.guard..ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
} from '@nestjs/common';
import { WsException } from '@nestjs/websockets';
import { MemoryStore, SessionData } from 'express-session';
import { Socket } from 'socket.io';
import { websocketCookieParse } from '@/auth/session/cookieParser';
import { MEMORY_STORE } from '@/auth/session.module';
import { User } from '@/user/domain/user.entity';

export interface SessionSocket extends Socket {
session?: User;
}

interface PassportSession extends SessionData {
passport: { user: User };
}

@Injectable()
export class WebSocketSessionGuard implements CanActivate {
constructor(
@Inject(MEMORY_STORE) private readonly sessionStore: MemoryStore,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const socket: SessionSocket = context.switchToHttp().getRequest();
const cookieValue = websocketCookieParse(socket);
const session = await this.getSession(cookieValue);
socket.session = session.passport.user;
return true;
}

private getSession(cookieValue: string) {
return new Promise<PassportSession>((resolve, reject) => {
this.sessionStore.get(cookieValue, (err: Error, session) => {
if (err || !session) {
reject(new WsException('forbidden chat'));
}
resolve(session as PassportSession);
});
});
}
}
90 changes: 90 additions & 0 deletions packages/backend/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Inject, UseFilters, UseGuards } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from 'winston';
import {
SessionSocket,
WebSocketSessionGuard,
} from '@/auth/session/webSocketSession.guard.';
import { WebSocketExceptionFilter } from '@/middlewares/filter/webSocketException.filter';
import { StockService } from '@/stock/stock.service';
import { ChatService } from '@/chat/chat.service';
import { Chat } from '@/chat/domain/chat.entity';

interface chatMessage {
room: string;
content: string;
}

interface chatResponse {
likeCount: number;
message: string;
type: string;
createdAt: Date;
}

@WebSocketGateway({ namespace: 'chat' })
@UseFilters(WebSocketExceptionFilter)
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
@Inject('winston') private readonly logger: Logger,
private readonly stockService: StockService,
private readonly chatService: ChatService,
) {}

@UseGuards(WebSocketSessionGuard)
@SubscribeMessage('chat')
async handleConnectStock(
@MessageBody() message: chatMessage,
@ConnectedSocket() client: SessionSocket,
) {
const { room, content } = message;
if (!client.rooms.has(room)) {
client.emit('error', 'You are not in the room');
this.logger.warn(`client is not in the room ${room}`);
return;
}
if (!client.session || !client.session.id) {
client.emit('error', 'Invalid session');
this.logger.warn('client session is invalid');
return;
}
const savedChat = await this.chatService.saveChat(client.session.id, {
stockId: room,
message: content,
});
this.server.to(room).emit('chat', this.toResponse(savedChat));
}

async handleConnection(client: Socket) {
const room = client.handshake.query.stockId;
if (!room || !(await this.stockService.checkStockExist(room as string))) {
client.emit('error', 'Invalid stockId');
this.logger.warn(`client connected with invalid stockId: ${room}`);
client.disconnect();
return;
}
if (room) {
client.join(room);
this.logger.info(`client joined room ${room}`);
}
}

private toResponse(chat: Chat): chatResponse {
return {
likeCount: chat.likeCount,
message: chat.message,
type: chat.type,
createdAt: chat.date?.createdAt || new Date(),
};
}
}
13 changes: 13 additions & 0 deletions packages/backend/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SessionModule } from '@/auth/session.module';
import { ChatGateway } from '@/chat/chat.gateway';
import { Chat } from '@/chat/domain/chat.entity';
import { StockModule } from '@/stock/stock.module';
import { ChatService } from '@/chat/chat.service';

@Module({
imports: [TypeOrmModule.forFeature([Chat]), StockModule, SessionModule],
providers: [ChatGateway, ChatService],
})
export class ChatModule {}
21 changes: 21 additions & 0 deletions packages/backend/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Chat } from '@/chat/domain/chat.entity';

interface ChatMessage {
message: string;
stockId: string;
}

@Injectable()
export class ChatService {
constructor(private readonly dataSource: DataSource) {}

async saveChat(userId: number, chatMessage: ChatMessage) {
return this.dataSource.manager.save(Chat, {
user: { id: userId },
stock: { id: chatMessage.stockId },
message: chatMessage.message,
});
}
}
29 changes: 29 additions & 0 deletions packages/backend/src/chat/domain/chat.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { ChatType } from '@/chat/domain/chatType.enum';
import { DateEmbedded } from '@/common/dateEmbedded.entity';
import { Stock } from '@/stock/domain/stock.entity';
import { User } from '@/user/domain/user.entity';

@Entity()
export class Chat {
@PrimaryGeneratedColumn()
id: number;

@ManyToOne(() => User, (user) => user.id)
user: User;

@ManyToOne(() => Stock, (stock) => stock.id)
stock: Stock;

@Column()
message: string;

@Column({ type: 'enum', enum: ChatType, default: ChatType.NORMAL })
type: ChatType = ChatType.NORMAL;

@Column({ name: 'like_count', default: 0 })
likeCount: number = 0;

@Column(() => DateEmbedded, { prefix: '' })
date?: DateEmbedded;
}
6 changes: 6 additions & 0 deletions packages/backend/src/chat/domain/chatType.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const ChatType = {
NORMAL: 'NORMAL',
BROADCAST: 'BROADCAST',
};

export type ChatType = (typeof ChatType)[keyof typeof ChatType];
2 changes: 1 addition & 1 deletion packages/backend/src/configs/devTypeormConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ export const typeormDevelopConfig: TypeOrmModuleOptions = {
database: process.env.DB_NAME,
entities: [__dirname + '/../**/*.entity.{js,ts}'],
logging: true,
};
};
4 changes: 3 additions & 1 deletion packages/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { NestFactory } from '@nestjs/core';
import * as session from 'express-session';
import * as passport from 'passport';
import { AppModule } from './app.module';
import { MEMORY_STORE } from '@/auth/session.module';
import { sessionConfig } from '@/configs/session.config';
import { useSwagger } from '@/configs/swagger.config';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(session(sessionConfig));
const store = app.get(MEMORY_STORE);
app.use(session({ ...sessionConfig, store }));
app.useGlobalPipes(new ValidationPipe({ transform: true }));
useSwagger(app);
app.use(passport.initialize());
Expand Down
Loading

0 comments on commit a4ab235

Please sign in to comment.