Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 23 additions & 23 deletions .github/workflows/auto-label-assign.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
name: PR 자동 라벨링 및 담당자 할당

on:
pull_request:
types: [opened, reopened]
pull_request:
types: [opened, reopened]

jobs:
add-labels-assign:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
add-labels-assign:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2

- name: Add labels and assign PR creator
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
PR_CREATOR=${{ github.event.pull_request.user.login }}
- name: Add labels and assign PR creator
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
PR_CREATOR=${{ github.event.pull_request.user.login }}

# Add labels
curl -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/labels \
-d '{"labels":["매운맛🔥", "진행 중 🏃"]}'
# Add labels
curl -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/labels \
-d '{"labels":["매운맛🔥", "진행 중 🏃"]}'

# Assign PR creator
curl -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/assignees \
-d "{\"assignees\":[\"$PR_CREATOR\"]}"
# Assign PR creator
curl -X POST -H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/issues/$PR_NUMBER/assignees \
-d "{\"assignees\":[\"$PR_CREATOR\"]}"
10 changes: 5 additions & 5 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"printWidth": 100,
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"trailingComma": "es5"
}
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,15 @@ _위 이미지는 판다마켓의 대표 이미지입니다._ 📸
### 브랜치 설명

1. **basic (part1): 스프린트 미션 1 ~ 4 FE 요구사항**

- 기본적인 웹 애플리케이션 기능 구현을 위한 초기 브랜치입니다. HTML, CSS, JavaScript 등을 사용해 기본을 다집니다.
- **스프린트 미션 1부터 4까지**의 프론트엔드 내용을 포함하고 있어요.

2. **react (part2): 스프린트 미션 5 ~ 7 FE 요구사항**

- React 라이브러리를 사용해 프론트엔드 기능을 구현하는 브랜치입니다. 컴포넌트 기반 아키텍처와 상태 관리를 배웁니다.
- **스프린트 미션 5부터 7까지, 그 이후**의 프론트엔드 내용을 포함하고 있어요.
- 만약 스프린트 미션 9부터 프론트엔드 코드를 Next가 아닌 React로 구현하고 싶다면 react 브랜치를 사용해요.

3. **next (part3,4): 스프린트 미션 8 FE 요구사항~**

- Next.js를 사용해 서버 사이드 렌더링(SSR)과 정적 사이트 생성(SSG) 등 고급 기능을 구현합니다.
- **스프린트 미션 8부터** 시작하는 프론트엔드 내용을 포함하고 있어요.
- 만약 스프린트 미션 9부터 프론트엔드 코드를 React가 아닌 Next로 구현하고 싶다면 next 브랜치를 사용해요.
Expand Down
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
PORT=4000

DATABASE_URL="postgresql://postgres:example3@localhost:5432/panda_db?schema=public"

JWT_SECRET = "myTestSecretKey122333444"
JWT_ACCESS_EXPIRES_IN = "1h"
JWT_REFRESH_EXPIRES_IN = "2w"

GOOGLE_CLIENT_ID = "exampleID"
GOOGLE_CLIENT_SECRET = "exampleSecret"
File renamed without changes.
82 changes: 82 additions & 0 deletions backend/api/articles/articles.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import prisma from '../../config/db.js';
import authRepo from '../auth/auth.repository.js';

export async function getArticleList(req, res) {
const { page = 1, pageSize = 10, orderBy = 'recent', keyword = '' } = req.query;

const limit = parseInt(pageSize);
const offset = (parseInt(page) - 1) * limit;

const articles = await prisma.article.findMany({
where: {
// 검색 쿼리
OR: [{ title: { contains: keyword } }, { content: { contains: keyword } }],
},
orderBy: orderBy == 'recent' ? { createdAt: 'desc' } : { favoriteCount: 'desc' },
skip: parseInt(offset),
take: parseInt(limit),
});
res.json(articles);
}

export async function getArticle(req, res) {
const { id } = req.params;
const article = await prisma.article.findUnique({
where: { id },
});
res.json(article);
}

//새로 등록 할 때만 유저 정보가 필요합니다.
export async function createArticle(req, res) {
const userId = req.auth?.userId;
const user = await authRepo.findById(userId);

const article = await prisma.article.create({
data: {
...req.body,
userName: user.name,
user: { connect: { id: user.id } },
},
});
Comment on lines +35 to +41
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클라이언트에서 넘어오는 모든 body 데이터를 스프레드 연산자로 사용하면 위험할수 있어요 ㅎㅎ
필요한 값들만 사용될수 있도록 처리하는게 좋습니다 !


res.status(201).send(article);
}

export async function updateArticle(req, res) {
const { id } = req.params;
const article = await prisma.article.update({
where: { id },
data: req.body,
});

res.json(article);
}

export async function deleteArticle(req, res) {
const { id } = req.params;
await prisma.article.delete({
where: { id },
});
res.sendStatus(204);
}

//소유권 검사 미들웨어
export async function checkOwner(req, res, next) {
try {
const { id } = req.params;
const userId = req.auth?.userId;
const product = await prisma.article.findUnique({
where: { id },
});
if (product.userId !== userId) {
const error = new Error('user is not owner');
error.code = 403;
throw error;
}
} catch (err) {
next(err);
} finally {
next();
}
}
27 changes: 27 additions & 0 deletions backend/api/articles/articles.routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Router } from 'express';
import { asyncHandeler } from '../../utils/errorHandler.js';
import { verifyAccessToken } from '../../middlewares/authGuard.js';
import {
getArticleList,
getArticle,
createArticle,
updateArticle,
deleteArticle,
checkOwner,
} from './articles.controller.js';

const router = Router();

/* === 게시글 api === */
router.get('', asyncHandeler(getArticleList));
router.get('/:id', asyncHandeler(getArticle));

//로그인한 유저(verifyAccessToken), 소유권이 있는 유저(checkOwner)
router.post('', verifyAccessToken, asyncHandeler(createArticle));
router.patch('/:id', verifyAccessToken, checkOwner, asyncHandeler(updateArticle));
router.delete('/:id', verifyAccessToken, checkOwner, asyncHandeler(deleteArticle));
router.post('/:id/checkOwner', verifyAccessToken, checkOwner, async (req, res) => {
res.status(200).json({ owner: true });
});

export default router;
2 changes: 2 additions & 0 deletions backend/api/articles/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import router from './articles.routes.js';
export default router;
155 changes: 155 additions & 0 deletions backend/api/auth/auth.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { createToken, refreshAccessToken, isValidEmail, isValidToken } from './utils/token.js';
import { createUser, getUser, getUserById, updateUser } from './auth.service.js';

const refreshTokenCookieOptions = {
path: '/auth/refresh', //쿠키 경로 고정.
httpOnly: true, //JS 접근 불가
secure: false, //로컬 실험용. -> 배포 true
sameSite: 'Lax', //로컬 실험용. -> 배포 'None'
};

//signup - 회원가입 email/pw/name -> 새로운 유저 생성.
export async function signup(req, res, next) {
try {
const { name, email, password } = req.body;
if (!name || !email || !password) {
const error = new Error('name, email, password 가 모두 필요합니다.');
error.code = 422;
throw error;
}

if (!isValidEmail(email)) {
const error = new Error('email 형식이 올바르지 않습니다.');
error.code = 422;
throw error;
}

//createUesr()는 user를 파라미터로 받으므로 {}로 넘겨준다.
const result = await createUser({ name, email, password });
return res.status(201).json(result);
} catch (error) {
next(error);
}
}

//login - email/pw를 이용해 엑세스 토큰 생성, 리프레시 토큰 세션/쿠키 포함.
export async function login(req, res, next) {
const { email, password } = req.body;
try {
if (!email || !password) {
const error = new Error('email, password 가 모두 필요합니다.');
error.code = 422;
throw error;
}

if (!isValidEmail(email)) {
const error = new Error('email 형식이 올바르지 않습니다.');
error.code = 422;
throw error;
}

const user = await getUser(email, password); //실패 시 함수 안에서 에러 throw
const accessToken = createToken(user);
const refreshToken = createToken(user, 'refresh');

await updateUser(user.id, { refreshToken });
res.cookie('refreshToken', refreshToken, refreshTokenCookieOptions);
res.status(200).json({ ...user, accessToken });
} catch (error) {
next(error);
}
}

//logout - 리프레쉬 토큰을 쿠키에서 지웁니다. (액세스 토큰 지우는 건 프론트에서 처리)
export async function logout(req, res, next) {
try {
res.clearCookie('refreshToken', refreshTokenCookieOptions);
res.status(200).json({ ok: true });
} catch (error) {
next(error);
}
}

//refresh - 리프레쉬 토큰이 있을 경우, 액세스/리프레쉬 토큰 재발급
export async function refresh(req, res, next) {
try {
const refreshToken = req.cookies.refreshToken;
const userId = req.auth?.userId || req.user?.id;
//auth에 있으면 기존 미들웨어,
//없으면 passport의 jwtStrategy 미들웨어로 보고 user에서 찾음.
//console.log("userId: " + userId);

//jwt 검증으로 저장된 userId라서 id는 굳이 검사과정이 필요 x

const newAccessToken = await refreshAccessToken(userId, refreshToken);
const newRefreshToken = createToken({ id: userId }, 'refresh'); //액세스 토큰 갱신과 함께 리프레쉬 토큰도 갱신. 무한로그인 유지
await updateUser(userId, { refreshToken: newRefreshToken });
res.cookie('refreshToken', newRefreshToken, refreshTokenCookieOptions);

return res.status(200).json({
ok: true,
accessToken: newAccessToken,
});
} catch (error) {
next(error);
}
}

//accessToken이 있을 경우 refresh 토큰 발급
export async function setRefreshToken(req, res, next) {
try {
const userId = req.auth?.userId || req.user?.id; //인증 미들에어를 통해 req로 넘어오는 정보.
const user = await getUserById(userId); //실패 시 함수 안에서 에러 throw <- userServide.js 함수
console.log('userName: ' + user.name);
const newRefreshToken = createToken(user, 'refresh');
console.log('refreshToken: ' + newRefreshToken);
await updateUser(userId, { refreshToken: newRefreshToken });
res.cookie('refreshToken', newRefreshToken, refreshTokenCookieOptions);

return res.status(200).json({ ok: true });
} catch (error) {
next(error);
}
}

export async function check(req, res, next) {
try {
const authHeader = req.headers.authorization;
if (!authHeader && !authHeader.startsWith('Bearer ')) {
const error = new Error('token 양식 오류.');
error.code = 401;
throw error;
}
Comment on lines +118 to +122
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authHeader가 falsy한 값이라하면 이미 undefined/null이기때문에 .startsWith가 호출된다면 에러가 발생할꺼 같아요 ㅎㅎ AND 연산이 아닌 OR 연산으로 처리해야 될것 같네요!

const accessToken = authHeader.split(' ')[1];
if (!isValidToken(accessToken)) {
const error = new Error('UnauthorizedError');
error.code = 403;
throw error;
}
return res.status(200).json({ authenticated: true });
} catch (error) {
return res.status(403).json({ authenticated: false });
}
}

export async function oauthLogin(req, res, next) {
try {
const { id } = req.user;
const user = await getUserById(id); //실패 시 함수 안에서 에러 throw
//console.log("userName: " + user.name);
const accessToken = createToken(user);
const refreshToken = createToken(user, 'refresh');

//성공 시 리프레시 토큰은 쿠키에 저장.
//(백에 저장되는 거라 자동 리프레쉬가 안되네요.)
res.cookie('refreshToken', refreshToken, refreshTokenCookieOptions);

//액세스 토큰은 프론트 리디렉트 라우터에 쿼리로 전송. (이게 가장 현실적인 방법인 듯 합니다.)
//프론트에서 받으면 바로 메인페이지로 페이지를 날리므로 일단 안전합니다.
res.redirect(`http://localhost:3000/oauth?accessToken=${accessToken}`);

// 액세스 토큰을 리턴 하는 방식이 아니라 프론트 원래 페이지로 리다이렉트를 해줘야 해서 res.json()은 없음.
} catch (err) {
next(err);
}
}
Loading