Git Product home page Git Product logo

wow's Introduction

WOW: Wap of World

동아리 홈페이지를 만들어보자

Back

TypeScript NestJS MySQL TypeORM AWS S3

Front

TypeScript Vite React 18 Styled Components TailwindCSS React Query Zustand


Install & Execute

client/.env.local

VITE_APP_BASE_URL=http://localhost:8080
VITE_APP_AWS_S3_URL = https://<your bucket>.s3.ap-northeast-2.amazonaws.com/

server/.env

SERVER_PORT=8080
CLIENT=http://localhost:3000

MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=

ACCESS_TOKEN_SECRET=access-token_secret
REFRESH_TOKEN_SECRET=refresh-token_secret

GITHUB_ID=
GITHUB_SECRET=
GITHUB_REDIRECT_URI=http://localhost:8080/auth/github/callback

GOOGLE_ID=
GOOGLE_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback

S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=ap-northeast-2
S3_BUCKET=
S3_PROFILE_BUCKET=

yarn add -g cocurrently && yarn start

or

npm install -g concurrently
npm run start_n

Main page


Sign up


Write page


Detail page


Mypage



Nest Js module 구조

entity : TypeORM 은 저장소 디자인 패턴을 지원하므로 사용합니다. entity에서 테이블을 생성하고 관계를 정의합니다.
controller : 들어오는 요청의 경로와 응답을 설정합니다. 라우팅 기능을 담당합니다.
service : 비지니스 로직을 담당합니다.
repository : sql 관련 로직을 담당합니다.

응답의 흐름 : controller -> service -> repository


ERD


TypeORM

article.repository.ts

#1
const articles = this.createQueryBuilder('article')
    .orderBy('article.createdAt', 'DESC') //article 내림차순으로
    .addOrderBy('article.id', 'DESC') //같을 때 최근 순으로
    .leftJoin('article.user', 'user') 
    .leftJoinAndSelect('article.tagList', 'tag')
    .leftJoinAndSelect('article.images', 'article_image')
    .addSelect(['user.id', 'user.username', 'user.email'])
    .where('article.fk_user_id = :userId', { userId: userId })
    .loadRelationCountAndMap('article.comments_count', 'article.comments');

leftJoin('article.조인할엔티티', '별칭') : join 후 addSelect 또는 select 를 따로 해야한다.

ex) user: User { id: 3, username: 'kim', email: '[email protected]' }

leftJoinAndSelect('article.조인할엔티티', '별칭') : join 후 select 까지 해준다. join한 테이블의 모든 컬럼을 select 한다.

ex) tagList: [ [Tag], [Tag], [Tag], [Tag], [Tag], [Tag] ]

ex) user: User {
       id: 3,
       username: 'kim',
       email: '[email protected]',
       password: ...,
       hashedRt: ....,
       createdAt: 2022-07-29T16:24:50.593Z,
       updatedAt: 2022-07-30T13:28:07.000Z
    }

Many는 array로 One은 instance로 가져온다.

loadRelationCountAndMap('article.컬럼명', 'count할 엔티티') : group by의 COUNT 함수 역할을 해준다.

ex) comments_count: 3

기본적으로 #1 의 형태로 article의 목록을 받아오며 조건을 조정하여 tag별로, user별로, articleid별로, 순서별로 받아올 수 있다.

#2
const article = this.createQueryBuilder('article')
    .where('article.id = :id', { id })
    .leftJoin('article.user', 'user')
    .addSelect(['user.id', 'user.username', 'user.email'])
    .leftJoinAndSelect('article.tagList', 'tag')
    .leftJoinAndSelect('article.comments', 'comments')
    .addOrderBy('comments.createdAt', 'DESC')
    .leftJoin('comments.user', 'comment_user')
    .addSelect([
    'comment_user.id',
    'comment_user.username',
    'comment_user.email',
    ])
    .leftJoinAndSelect('article.images', 'article_image');

#2 와 같이 article.comments를 join하고 그 안에서 comments.user를 join할 수 있다.

ex)
   "comments": [
        {
            "id": 5,
            "text": "cc",
            "fk_user_id": 3,
            "fk_article_id": 3,
            "createdAt": "2022-07-31T08:51:27.982Z",
            "updatedAt": "2022-07-31T08:51:27.982Z",
            "user": { //comments안에서 user가 join됨
                "id": 3,
                "username": "kim",
                "email": "[email protected]"
            }
        },
        ..
        ....
    ]

Query Builder가 아닌 다음과 같이 find option을 사용할 수도 있고

#3
  async findCommentsByArticleId(articleId: number): Promise<Comment[]> {
    return await this.find({
      where: { fk_article_id: articleId },
      order: { createdAt: 'DESC' },
      relations: ['user'],
    });
  }

EntityManager를 통해 sql문으로 실행할 수도 있다.
tag.repository.ts

#4
const tagList = this.manager.query(
    `
    select tag.id, tag.name, articles_count from (
    select count(fk_article_id) as articles_count, fk_tag_id from article_tags
    inner join article on article.id = fk_article_id
        and article.fk_user_id = ?
    group by fk_tag_id
    ) as q inner join tag on q.fk_tag_id = tag.id
    order by articles_count desc
    `,
    [userId],
);

mypage에서 내가 쓴 글 태그별 Count

tag.repository.ts

select count(fk_article_id) as articles_count, fk_tag_id from article_tags
    inner join article 
    on article.id = fk_article_id and article.fk_user_id = ?
    group by fk_tag_id

결과 예시:

articles_count fk_tag_id
1 3
2 4
1 6
2 10
.....
select tag.id, tag.name, articles_count from (
    select count(fk_article_id) as articles_count, fk_tag_id from article_tags
        inner join article on article.id = fk_article_id
          and article.fk_user_id = ?
        group by fk_tag_id
    ) as q inner join tag on q.fk_tag_id = tag.id
    order by articles_count desc;

결과 예시:

id name articles_count
4 python 2
10 java 2
11 test 2
5 js 1
...

결과적으로 user_id 넣으면 해당 user가 쓴 글들의 tag와 tag수 를 반환한다.


NestJs reqest lifecycle


인증 전략


3 : Access/refresh token 생성
auth.service.ts

  async getTokens(userId: number, email: string) {
    const [access_token, refresh_token] = await Promise.all([
      this.jwtService.signAsync(
        { userId, email, sub: 'access_token' },
        {
          secret: this.configService.get<string>('auth.access_token_secret'),
          expiresIn: '1h',
        },
      ),
      this.jwtService.signAsync(
        { userId, email, sub: 'refresh_token' },
        {
          secret: this.configService.get<string>('auth.refresh_token_secret'),
          expiresIn: '30d',
        },
      ),
    ]);
    return { access_token, refresh_token };
  }


4 : hashedRt 저장
auth.service.ts

  async updateRtHash(userId: number, rt: string) {
    const hash = await this.hashData(rt);
    return this.userRepository.update({ id: userId }, { hashedRt: hash });
  }

hashedRt는 요청 헤더의 refresh token의 검증용으로 사용한다.


5 : cookie 생성
auth.service.ts

  setTokenCookie(
    res: Response,
    tokens: { access_token: string; refresh_token: string },
  ) {
    res.cookie('access_token', tokens.access_token, {
      maxAge: 1000 * 60 * 60 * 1, // 1h
      httpOnly: true,
    });
    res.cookie('refresh_token', tokens.refresh_token, {
      maxAge: 1000 * 60 * 60 * 24 * 30, // 30d
      httpOnly: true,
    });
  }

7, 8 : access token검증 및 만료시 재발급
jwt-auth.middleware.ts

@Injectable()
export class JwtAuthMiddleware implements NestMiddleware {
  constructor(
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {}
  async use(
    @Req() req: Request,
    @Res() res: Response,
    @Next() next: NextFunction,
  ) {
    const accessToken: string | undefined = req.cookies['access_token'];
    const refreshToken: string | undefined = req.cookies['refresh_token'];
    try {
      if (!accessToken) {
        throw new HttpException('액세스 토큰 없음', 401);
      }
      const accessTokenData = await this.jwtService.verify(accessToken, {
        secret: this.configService.get('auth.access_token_secret'),
      });
      req.userId = accessTokenData.userId;
      const diff = accessTokenData.exp * 1000 - new Date().getTime();

      if (diff < 1000 * 60 * 30 && refreshToken) {
        await this.authService.refresh(res, refreshToken);
      }
    } catch (e) {
      if (!refreshToken) return next();
      try {
        const userId = await this.authService.refresh(res, refreshToken);
        req.userId = userId;
      } catch (e) {}
    }
    return next();
  }
}

auth.service.ts

  async refresh(res: Response, refresh_token: string) {
    const refreshTokenData = await this.jwtService.verify(refresh_token, {
      secret: this.configService.get('auth.refresh_token_secret'),
    });
    const user = await this.userRepository.findById(refreshTokenData.userId);

    if (!user || !user.hashedRt)
      throw new HttpException(
        '존재하지 않는 user이거나 signin상태가 아닙니다',
        404,
      );

    const rtmatches = await this.compareData(user.hashedRt, refresh_token);
    if (!rtmatches)
      throw new HttpException('refresh토큰이 일치하지 않습닏다', 404);

    // 15일보다 적게 남았을 경우 refresh token 갱신
    const now = new Date().getTime();
    const diff = refreshTokenData.exp * 1000 - now;
    if (diff < 1000 * 60 * 60 * 24 * 15) {
      refresh_token = await this.jwtService.signAsync(
        {
          userId: user.id,
          email: user.email,
          sub: 'refresh_token',
        },
        {
          secret: this.configService.get<string>('auth.refresh_token_secret'),
          expiresIn: '30d',
        },
      );
      await this.updateRtHash(user.id, refresh_token);
    }

    const access_token = await this.jwtService.signAsync(
      { userId: user.id, email: user.email, sub: 'access_token' },
      {
        secret: this.configService.get<string>('auth.access_token_secret'),
        expiresIn: '1h',
      },
    );

    this.setTokenCookie(res, { access_token, refresh_token });
    return user.id;
  }

미들웨어에서 access token여부를 검증하고 재발급할 기간이라면 refresh가 진행된다. 재발급은 refresh token의 user의 존재 여부를 확인하고 hashedRt와 refresh token의 일치 여부를 확인한 후 access token을 발급한다.

refresh token이 만료되면 새로 로그인 해야한다.


SetMetadata, Guard

public.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const Public = () => SetMetadata('isPublic', true);
ex)

  @Public()
  @Get('/')
  getAllArticles(@Query('cursor') cursor?: number) {
    return this.articleService.getAllArticles(cursor);
  }

위처럼 사용한다. getAllArticles 핸들러에 isPublic : true 인 meta data가 들어가 있다. class에도 사용할 수 있다.

auth-guard.guard.ts

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  public canActivate(context: ExecutionContext): boolean {
    // Public()이 클래스전체나 핸들러에 있을 경우 auth 건너 뜀
    const isPublic = this.reflector.getAllAndOverride('isPublic', [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const req = context.switchToHttp().getRequest();
    if (!req.userId) throw new HttpException('권한이 없습니다', 401);

    return true
  }
}

meta data에 접근은 @nestjs/core 에서 제공하는 Reflector를 사용한다. reflector는 2개의 인자로 meta data의 key와 context의 정보를 받는다.

context는 guard의 canActivate(), interceptor의 intercept() 와 같은 method에서 사용할 수 있다. + ( filters, createParamDecorator )
주의) ExecutionContext는 context가 아니다. context활용 유틸리티다. 즉 핸들러와 같은 곳에서 context 를 얻을 수 없다.

app.module.ts

  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],

Guard를 전역으로 사용하고 있다. 모든 컨트롤러와 라우터 핸들러에 대해 실행된다. 즉 guard는 isPublic에 대한 metadata를 확인하고 true면 로그인 검증을 하지않고 metadata가 없다면 로그인 상태를 필요로 한다. ( 미들웨어 후에 가드가 동작한다 request life cycle참조 )


Custom decorators

get-current-user-id.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetCurrentUserId = createParamDecorator(
  (_data: unknown, context: ExecutionContext): number => {
    const req = context.switchToHttp().getRequest();
    return req.userId;
  },
);

auth.controller.ts

ex)

  @Delete('/logout')
  async logout(
    @Res({ passthrough: true }) res: Response,
    @GetCurrentUserId() userId: number,
  ): Promise<void> {
    await this.authService.logout(userId);
    this.authService.clearTokenCookie(res);
  }

Custom Type

index.d.ts

export default {};

declare global {
  namespace Express {
    interface Request {
      userId: number;
    }
  }
}

jwt-auth.middleware.ts

ex)
req.userId = accessTokenData.userId;

// declare 설정이 없다면 userId 타입을 못찾고 에러가 난다.

d.ts는 TS의 타입 선언 파일로 JS로 컴파일 되지않는다. JS에서 에러없이 활용할 수 있는 변수들을 TS에서 타입 정의가 되지않아 사용할 수 없다면 그런 부분을 해결해준다.

TS에서 모듈 활용을 위한 타입 선언이다. ( 모듈의 기능/변수 수정과 무관하다. )

wow's People

Contributors

alstn113 avatar sjyoung428 avatar cornpip avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.